On this page

← All documentation

Skins

Writing a skin: defineSkin, the playfield reference, native drawing, player settings.

A skin is TypeScript code. Each time a map starts, the game runs the chosen skin's skin.ts; this script places the native playfield (position, sizes, scroll direction, images and tints of each lane) and builds the HUD of the WebView overlay (progress bar, combo, accuracy…) with the mods' hud.* API. During play, its event handlers change the playfield (playfield.update, lanes.set). Native rendering draws the playfield and, with the stage permission, the skin's native elements (see Native drawing); the HUD lives entirely in the overlay.

Where skins live

  • Game skins: plain folders under skins/ shipped next to prism.exe (in development, the repository's skins/ folder, found by walking up from the executable to the folder containing Cargo.lock), loaded from disk exactly like the player's skins: no skin is embedded in the binary, and each one can be read, modified, exported and deleted like the others (no "Copy to edit"). The engine knows none of them. The game's skins:
    • default ("Prism Default", the default setting): skins/default/, the osu! skin "PRISM // arrow (cap ends)" (by april, assets/skins/default.osk) converted from its skin.ini for the game's 4K to 7K layouts. 62 px lanes (4K; 58, 54 and 50 px in 5K, 6K and 7K) on a virtual screen of 480 (laneWidth 0.129 in 4K, no gap), #07090f96 lanes, a one-pixel light edge (the StageLeft and StageRight images), dark arrows (left, down, up, right, plus center, upleft and upright for odd or 6K layouts) and their keys lit on press, hold body and capped end piece. HitPosition 430 places the bottom of the notes on the line: the notes are centered half a lane higher (receptorY = 430/480 − laneWidth/2) and the key images carry transparent rows above the arrow so that their center is the arrow's center. Combo (ComboPosition 240) and last judgement (ScorePosition 188) are centered. The archive's @2x images are used as they are (4K keys, 5K center and 6K upleft/upright scaled down to 198 px wide, the other layouts reuse the 4K arrows); the lighting is kept (StageLight: stageLight tinted #aae4ff (ColourLight1); LightingN: hitLight, 8 images fx/explosion-{n}.png, played on each hit; LightingL: holdLight, 6 images fx/holdlight-{n}.png, at LightFramePerSecond 60, on the hit position LightPosition 430, half a lane below the receptor line). Not in the game: the judgement, score and combo images (the judgement is a mod), the lane lines (5 % white), the sounds, the cursor and the hold body animation (only one of the 8 images is kept). The original archive stays outside the game. The game's font is assets/font.ttf, outside the skin; the folder stays under 900 KiB (test);
    • default-circle ("Default Circle", by tekkito2): skins/default-circle/, the 4K layout of the osu! skin "tekkito2 ft jb the voice tu perfume a chanel" ported from its skin.ini: 72 px lanes on a virtual screen of 480 (laneWidth 0.15, no gap), judgement line at 440 (receptorY 0.9167, judgementLine), black lanes at 200/255, round notes (outer circle on lanes 0 and 3, inner on the others), combo and judgement centered below the playfield (ComboPosition 130, ScorePosition 150). The osu! skin's receptor, "stage hint" and lighting images (lightingN, lightingL, lighting, mania-stage-light) are transparent images: nothing is displayed at those places, as in osu! (no stageLight image). The notes are centered on the judgement line (middle of the note, noteOffsetY 0). Credits: the hold body and the hold end piece do not come from tekkito2's skin (its end piece is transparent, its body a gray 138 × 40,000 px image) but from the default skin ("PRISM // arrow (cap ends)" by april): hold_body.png is a copy of notes/body.png (256 × 32 px) and hold_cap.png a copy of notes/tail-cap.png (256 × 256 px, 3.7 KiB). These are standalone copies; the skin never references another skin's folder. The body is as wide as the notes (holdWidth 0.15), the end piece as wide as the body (holdEndSize 0.15 × 0.15, no holdEndOffset), white tints. The archive's heavy images (22 MB), the .psd files, the sounds, the cursor and the scores are not included: only 6 PNG files and the script, weighing about 30 KiB. The original archive is assets/skins/circle-default.osk (outside the game).
  • Player skins: one folder per skin in <data>\skins\<id>\ (%LOCALAPPDATA%\Prism\PrismNG\data\skins), or in the folder given by PRISM_SKINS_DIR (which only replaces this root of the player's data; the game's skins are still read from skins\ next to prism.exe). The list takes the game's skins first, then the player's; if two folders share the same identifier, the first one wins and a message reports it. The folder name is the skin's identifier on this machine (1 to 64 characters valid on Windows, not hidden). Without the chosen skin, the first available skin is used; with no skin at all, the engine plays its own defaults (white rectangles, no images) with a single visible notice "No skin found: place a skin folder in skins/". A folder without skin.ts is not a skin (reported on the Skins page).

A package contains skin.ts (a single script, with no import, 1 MiB at most), .png images and .ttf/.otf fonts. Files are always named by paths relative to the folder (notes/rouge.png): no .., absolute path or reserved Windows name; a symbolic link or junction that would leave the folder is rejected.

The script

export default defineSkin({
  id: "neon",            // mod id rules
  name: "Neon",
  version: "1.0.0",
  apiVersion: 2,         // version of the game's mod API
  author: "Moi",
  uses: { "pvng.judgement-display": "^1" },
  setup(play: PlayContext) {
    playfield.set({ laneWidth: 0.07, noteColor: "#ff4080", scroll: "up" });
    lanes.set({ column: 0, lane: { note: "notes/gauche.png" } });
    // HUD: hud.text(...), hud.group(...), see docs/modding.md
  },
});

defineSkin also accepts images (images loaded for the play session, see below) and permissions (["stage"] for native drawing).

setup(play) receives the launch context (PlayContext; ctx is already the global name used by ctx.on(...)): mode, layout (layout from the mode catalog), layoutSkin (its appearance, for example 4k), columns, screenWidth/screenHeight (pixels), song, and judgements (the judgement set for the play session). The script is reloaded from scratch at each launch: nothing accumulates from one play session to the next.

  • playfield.set(spec) merges spec (a PlayfieldSpec) into the playfield description: the fields provided replace those from previous calls, while the others remain.
  • lanes.set({ column, lane }) merges a LaneSpec into the column column (starting at 0, 32 columns at most).
  • A field that was never provided keeps the default appearance of the engine for the launched layout: a script without playfield.set draws the legacy playfield.
  • The HUD is no longer drawn by the skin: each element is provided by a mod (a folder in mods/), and the skin places and styles it with ctx.element("<package>/<element>").configure({ … }), after declaring it in uses (optional) and testing it with ctx.has. Values that change with every judgement or frame (combo, accuracy, progress…) are bindings (bind, fill, showWhen, animate, element) that mods set on their nodes and that the overlay resolves itself: see modding. The HUD section of skins/default/skin.ts (and that of skins/default-circle/skin.ts) serves as a complete example: it creates no hud.* node.
  • The elements of the game's mods: pvng.accuracy/accuracy, pvng.combo/combo, pvng.counters/timer, …/remaining, …/hits, …/misses, pvng.progress-bar/progress, pvng.fps/fps, pvng.pause-status/status (options in modding). All of them take x, y, anchor, size, and visible (except the bar: width, height, radius…), plus their colors. The default skin ships hits and misses with visible: false: the player's skin configuration can display them. Positions and sizes that depend on the screen are computed with play.screenWidth and play.screenHeight, as the default skin does.
  • The last judgement and the per-tier counters come from the mod pvng.judgement-display, in the colors of the play session's judgement tiers. A skin declares it in uses ("^1", or { version: "^1", feature: "…" } to name what is missing without it, as the default skin does), then places and styles it in its setup: ctx.element("pvng.judgement-display/judgement").configure({ x, y, anchor, size, animation, colors }) and …/counts (options in modding). Without the mod, ctx.has is false, a message says so on the Mods page, and the skin does without this display (see dependencies).
  • The hit bar is another mod, pvng.hit-bar. Add "pvng.hit-bar": "^1" to uses, then place its element if ctx.has("pvng.hit-bar"): ctx.element("pvng.hit-bar/bar").configure({ x: 0.5, y: 0.427, anchor: "top", width: 0.42, height: 0.055, length: 50, visible: true }). It uses the latest native non-Miss offsets and ordinary HUD primitives. Its presence does not depend on pvng.judgement-display. Skins can therefore choose separately the last judgement, the counters, and the visualization of hits.

The types (PlayfieldSpec, LaneSpec, LaneUpdate, PlayfieldAnchor, ScrollDirection, SpriteSize) are defined in Rust in crates/skin (#[derive(TsSchema)]) and generated into the mods' TypeScript SDK (mods/sdk/modding.d.ts).

Playfield reference

Positions are fractions of the screen (x of its width, y of its height, from the top-left corner); sizes are fractions of the screen height: the playfield keeps its proportions at any resolution.

Field Range Default Role
x, y 0 to 1 0.5; 0.5 position of the anchor point of the lanes box
anchor topLeft … bottomRight center point of the box placed at x, y
rotation −3600 to 3600 0 rotation of the entire playfield around the center of the lanes box, in degrees, clockwise
zoom 0 to 4 1 scale of the entire playfield around the same center
laneWidth 0.005 to 0.5 0.085 width of one lane
width 0.005 to 2 — total width; sets the lane width regardless of how many lanes there are (takes priority over laneWidth)
laneGap 0 to 0.1 0.003 space between two lanes: they are spaced laneWidth + laneGap apart, the gap never shrinks a lane
laneHeight 0 to 1 0.9 height of the lanes box
receptorY 0 to 1 0.85 (down), 0.15 (up) receptor line, from the top
scroll down, up down notes fall toward the receptors, or rise
noteSize 0 to 0.5 per side 0.0725 × 0.0725
receptorSize 0 to 0.5 0.0775 × 0.0775
holdWidth 0 to 0.5 0.02975 width of the hold body
holdMatchNoteWidth boolean false hold body as wide as the lane's note, with the end cap as wide as the body (holdWidth and the width of holdEndSize are ignored)
holdWidthScale 0.25 to 3 1 factor applied to the note width when holdMatchNoteWidth is active
holdEndSize 0 to 0.5 0.0725 × 0.015 size of the hold end (also per lane); height 0: no end cap
holdEndScale 0.1 to 3 1 multiplies the resolved size of the hold end; purely visual
holdEndFlip boolean false flips the hold end image relative to the automatic orientation; purely visual
holdEndOffset −0.5 to 0.5 0 shifts the hold end along the scroll direction, positive toward the receptor; purely visual
noteOffsetY −0.5 to 0.5 0 shifts notes and hold heads along the scroll direction, positive in the direction of the notes; purely visual
borderWidth 0 to 0.1 0 frame around the lanes box
borderColor color white color of the frame
judgementLine boolean false draws a line on each lane at the judgement line (the receptors' line)
judgementLineColor color white color of this line
judgementLineThickness 0.0005 to 0.02 0.003 thickness of this line, in screen heights
judgementLineAt center, top, bottom center where the line sits on the receptors: in the middle, on their top edge, or on their bottom edge (as the screen shows them)
laneCover boolean false lane cover: a block of color across the full width of the lanes, drawn above the notes, on the side where notes appear (at the top when they fall, at the bottom when they rise); it never extends past the receptor edge, so the receptors stay visible; purely visual (judgement, replays, and hold timing are unaffected)
laneCoverColor color #000000 color of the cover
laneCoverOpacity 0 to 1 1 multiplies the alpha of the cover color
laneCoverSize 0 to 1 0.3 how far the cover extends from the edge of the lanes box where notes appear, as a fraction of the height of that box
laneCoverFeather 0 to 0.2 0 softened edge: the end of the cover fades out over this length (fraction of the box height, never more than the cover itself); 0: hard edge
measureLines boolean false measure lines: a line across the full width of the playfield at the start of each measure of the map, scrolling like the notes (same speed, same direction, zoom and rotation included), drawn below the receptors and below the notes, above the lane background; purely visual
measureLineColor color white color of the measure lines
measureLineOpacity 0 to 1 0.35 multiplies the alpha of this color
measureLineThickness 0.0005 to 0.02 0.003 thickness of the measure lines, in screen heights
measureLineLength playfield, lanes playfield playfield: one continuous line across the full width of the playfield (gaps between lanes filled); lanes: one segment per lane, as wide as the lane, on that lane's receptor line (offsetY included)
measureLineOvershoot 0 to 0.5 0 overshoot on either side of the outer lanes, in screen heights (playfield only)
measureLineEvery 1 to 16 (integer) 1 one measure out of N is accented (twice as thick, at the color's full alpha), counting from the first measure; 1: no accent
beatLines boolean false beat lines: a fainter line at each of the other beats of the measure, below the measure lines
beatLineColor color white color of the beat lines
beatLineOpacity 0 to 1 0.12 multiplies the alpha of this color
beatLineThickness 0.0005 to 0.02 0.002 thickness of the beat lines, in screen heights
backgroundColor color transparent background of the lanes box, visible between the lanes; tints backgroundImage
backgroundImage .png path none image stretched over the lanes box
backgroundTint color #818181 multiplies the map's background image (22%, the historical dimming)
laneColor color #454d61 background of each lane; tints laneImage
noteColor, receptorColor, pressedColor, holdBodyColor, holdEndColor color white tints multiplied with the image
holdMissedStyle tint, hide tint a long note whose head was never hit, or that was released too early (the core game's "Miss" state, final for the note), stays on screen darkened until it leaves the screen (tint, even if the player presses again), or disappears as before (hide); also per lane
holdMissedColor color #808080 tint multiplied with the head, body, and end of a missed long note (colors multiply in linear light: #808080 keeps about a fifth of the brightness); also per lane
holdMissedOpacity 0 to 1 1 multiplies the alpha of a missed long note; also per lane
note, receptor, receptorPressed, holdBody, holdEnd .png path white rectangle, no image images for all lanes
laneImage .png path none image stretched over each lane
keyLightOn boolean true key light: the receptor reacts to its key, even on a lane with no notes; false: it does not react (the receptor image stays displayed)
receptorPressed .png path receptor image of the key light (the pressed receptor)
pressedColor color white color of the key light
keyLightOpacity 0 to 1 1 multiplies the alpha of the key light color
keyLightScale 0.25 to 3 1 scale of the pressed receptor around its center (1: its size)
keyLightFadeMs 0 to 500 0 duration (ms) of the key light's fade-in and fade-out; 0: instant, as before
stageLightOn boolean true lane lighting; false draws none, even with a stageLight image
stageLight .png path none lighting image for each lane while its key is held
stageLightColor color white tint of stageLight
stageLightOpacity 0 to 1 1 multiplies the alpha of the lane lighting
stageLightSize 0 to 0.5 per side 0 × 0 size of the lighting; 0: lane width, height following the image
stageLightScale 0.1 to 3 1 factor applied to the resolved size; the edge nearest the receptors stays on their line
stageLightOffsetY −0.5 to 0.5 0 shifts the lighting along the scroll direction (positive: direction of the notes)
stageLightFadeMs 0 to 500 100 duration (ms) of the lane lighting's fade-in and fade-out; 0: instant
hitLight .png path with {n} none hit flash: animation played once at the lane's receptors, on each judgement of that lane that is not a Miss (note, hold head, judged hold release), restarted by the next hit; {n} is replaced by 0, 1, 2…
hitLightOn all, off all hitLight plays on every hit (all) or never (off); the holdLight is a separate effect, it loops as long as a long note is held
hitLightFollowJudgement boolean false hitLight takes the color of the tier hit (colors of the active judgement) instead of hitLightColor; the opacity still applies
hitLightFrames 1 to 32 (integer) 1 number of hitLight frames
hitLightFps 1 to 240 60 frames per second (real time)
hitLightSize 0 to 0.5 per side 0 × 0 0: twice the lane width, height following the image
hitLightOffsetY −0.5 to 0.5 0 shifts the animation along the scroll direction (positive: direction of the notes)
hitLightColor color white tint of the animation
hitLightOpacity 0 to 1 1 multiplies the alpha of the animation
hitLightScale 0.1 to 3 1 factor applied to the resolved size (around the image center)
holdLight, holdLightFrames, holdLightFps, holdLightSize, holdLightOffsetY, holdLightColor, holdLightOpacity, holdLightScale like hitLight* none hold light: animation looping as long as a hold in the lane is held (from its head judged without a Miss to its release or end)
textureFilter linear, nearest linear filtering of the playfield textures
lanes at most 32 — per-lane values (LaneSpec)
LaneSpec has the thirteen column image and tint fields listed above
(note, receptor, receptorPressed, holdBody, holdEnd, laneImage,
stageLight, laneColor, noteColor, receptorColor, pressedColor,
holdBodyColor, holdEndColor, stageLightColor) for a single lane, the lane's
noteOffsetY, holdEndOffset, holdEndSize, holdEndScale,
holdEndFlip, stageLightOffsetY, stageLightSize,
holdMatchNoteWidth, and holdWidthScale values, the key light and lane lighting
settings (keyLightOn, keyLightOpacity,
keyLightScale, keyLightFadeMs, stageLightOn, stageLightOpacity,
stageLightScale, stageLightFadeMs), the look of the lane's missed long notes
(holdMissedStyle, holdMissedColor, holdMissedOpacity), and offsetX (−2 to 2 screen heights,
0 by default), which shifts the whole lane to the right (background, receptor,
notes, holds). If receptorPressed is absent, the receptor image
at the same level is also used when the key is pressed. Colors are
written as #rgb, #rgba, #rrggbb, or #rrggbbaa; a tint
multiplies its image in the shader. A note travels 0.8 screen heights
during the scroll time set by the player.

The judgement line (judgementLine) is one segment per lane, exactly as wide as the lane, centered on that lane's receptor line (including its offsetY offset): it therefore follows the zoom, rotation, and free placement of lanes, is drawn above the lane background and below the receptors, and changes neither judgement nor hit positions. Like the other values, it is set by the skin script, then the map script, then the player's configuration (skin editor, "Judgement line" group).

The lane cover (laneCover) is a single block for the whole playfield, spanning the width of the lane box, that follows the zoom and rotation. It is drawn after the notes, so on top of them, but never over the receptor area: whatever laneCoverSize is, it stops at the receptor edge closest to where notes appear. The softening (laneCoverFeather) is rendered with 24 bands of decreasing alpha in the same instance batch as everything else (no dedicated shader). The player adjusts it live in the skin editor, in a separate tab, "Lane cover" (group laneCover, next to "Judgement line"), including position (laneCoverSize) and color. This group is part of the host's catalog (field_catalog), not of the skin: all skins have it, the default skin, Default Circle, the player's skins, including a minimal skin, disabled by default. It is set on the playfield (not on a lane: selecting a lane only shows its own fields). Settings → Video → Playfield shows its state for the active skin (disabled, enabled with its size, or "per skin" as long as the player has not chosen anything) and an icon button "Open in skin editor": it sends editLayout { focus: "laneCover" } and the editor opens on the playfield, "Lane cover" tab. The values stay in skinConfig[skinId]: the Settings page keeps no copy of them. Nothing is judged or recorded differently: only the rendering changes.

With the player option "Follow scroll speed changes (SV)" (docs/game.md, disabled by default), measure lines and beat lines follow the same scroll map as the notes; the skin values do not change.

Measure lines (measureLines) and beat lines (beatLines) are native: the engine draws them, with no mod or scene primitive. They are disabled by default, including in the default skin (a skin turns them on with measureLines: true in its setupPlayfield, the map with playfield.update, the player in the editor). The line schedule is computed once when the game starts, on the engine thread (not the rendering thread), from the chart's timing points already adjusted to the play speed: a BPM point starts a measure, beats follow every 60 / bpm seconds until the next point, a measure has signature beats (1 to 64, otherwise 4), and before the first point its tempo extends backward to 0. The lines are therefore correct at any rate (rate) and follow BPM changes. Same semantics as the mods' game.beat. On each frame, the renderer only does two binary searches in this schedule (capped at 100,000 lines) for the visible window, with no allocation, and draws at most 256 measure lines and 256 beat lines: a line's position is that of a note at the same instant (scroll time, direction, visual offset, zoom, rotation, receptorY), in the same instance batch as everything else. They appear in the game, the replay, the viewer, the preview, and the skin editing session (the demo has a BPM of 150), but not in the map editor, which has its own grid. What is supported, depending on the source:

  • osu!: each non-inherited timing point provides its BPM and its signature (meter); an absurd signature is treated as 4.
  • StepMania / Etterna (.sm, .ssc): BPM changes count, the measure always has 4 beats (#TIMESIGNATURES is not read). Stops (#STOPS), delays, and warps are already built into the note times by the import, without a timing point: the lines do not show them (they follow the tempo alone).
  • A chart without a BPM point has no lines; two points at the same instant: the last one wins; a BPM above 60,000 is ignored.

The values stay in skinConfig[skinId] (precedence: skin script, map script, player configuration, live). They are set in the skin editor, "Measure lines" tab (group measure, two sections: "Measure lines" and "Beat lines"), on the playfield only: none of these values exist per lane. Nothing is judged or recorded differently.

Missed long notes (holdMissedStyle) read the state already known to the game core (head never hit, or released too early) without modifying it; neither judgement nor the replay format changes, so the replay shows the same thing as the game. In the skin editor ("Colors" group, "Missed long notes" section), the editing demo always displays a hold as if it were missed (the first one whose head is still in front of the receptors, without judging anything) so you can see the effect live.

Hold width following the notes. With holdMatchNoteWidth (boolean, false by default; the default and default-circle skins set it to true), a hold's body is as wide as its lane's note (its resolved size: noteSize from the skin, the map, the player, or the lane) multiplied by holdWidthScale (0.25 to 3, 1 by default), and the end cap is as wide as the body; holdWidth and the width of holdEndSize are then ignored. The end cap's height follows the proportions of its image, unless a skin or the player provides holdEndSize (its height then applies). Enlarging notes in the editor therefore enlarges bodies and end caps together, live. Both values also exist per lane (LaneSpec). Visual offsets. noteOffsetY and holdEndOffset (−0.5 to 0.5 screen height, 0 by default) move images along the scroll axis, without ever touching judgement, hold timing, score, or replays (just like the player's visual offset). noteOffsetY moves notes and hold heads: positive, in the direction the notes travel (toward the receptors and beyond); negative, backward. 0 centers the note on the receptor line, as in Default Circle, where the judgement line is the middle of the note. The hold body and cap do not follow: they stay on the end time. holdEndOffset moves the end image: positive, toward the receptor ("30 px before the end" is a positive value), negative, the opposite way. An end whose time falls outside the drawn area is not drawn (it is no longer stuck to the edge of the screen). The end image is authored like a note: its tip (the bottom of the image) aims at the receptors when notes scroll down, as in osu! skins; the game flips it so that it points away from the receptors in the playfield's frame of reference: flipped when notes scroll down, as is when they scroll up, for any rotation and any zoom.

Hold end (editor tab). The end image of a hold has its own group in the editor catalog (holdEnd, a single holdEnd section, "Hold end" tab): holdEnd (image), holdEndColor (tint), holdEndSize (size, per lane too), holdEndScale (0.1 to 3, 1 by default: multiplies the resolved size, the end position does not move), holdEndOffset (offset along the scroll) and holdEndFlip (boolean, false: flips the image relative to the automatic orientation described above). Each field exists for the whole playfield and per lane (LaneSpec), with the usual precedence (skin < map script < player; a lane's own value wins over the playfield's value from the same layer), live in the editor, and remains purely visual: never judgement, hold timing, score, or replay. The width follows the body when holdMatchNoteWidth is active; its height comes from the image unless holdEndSize provides it. In the editing demo, every hold end on screen is a frame you click to select it (highlighted in the theme colors, only the "Hold end" tab, values for this lane); drag it along the lane (or use the up/down arrows) to set holdEndOffset, and Delete resets the lane's hold end values to those of the skin. The "Hold end" row in the layers list selects the ends of all lanes (playfield values); the eye icon hides the frames. The frames come from the host (editLayout.holdEnds, HoldEndBox entries normalized like laneBoxes), computed by the same code as the drawing (Layout::hold_end_rect), so the frame is exactly the drawn image (zoom included).

Lane lighting (stage light). With a stageLight image, each lane lights up as long as its key is held: a quad with the lane's width (stageLightSize.width at 0) and the height of stageLightSize.height, or the image's aspect ratio if it is 0, multiplied by stageLightScale, tinted by stageLightColor and with alpha multiplied by stageLightOpacity, drawn above the receptor and below the notes, its edge closest to the receptors on the lane's receptor line (including the offset stageLightOffsetY, positive in the notes' scroll direction) and spreading toward the side the notes come from. Its alpha rises from 0 to 1 while the key is held and falls off linearly after release over stageLightFadeMs (100 ms by default; 0: instant), in real frame time, computed by the stage without allocation or per-frame script. stageLightOn (boolean, true by default, skin < map < player layers, live, per lane too) turns it off without touching the image, color, or animations. Without a stageLight image (default), nothing is drawn. The Default Circle skin has none: its lighting images (lightingN, lightingL, lighting, mania-stage-light) are transparent 1×1 pixels in the original .osk.

Key lighting (key light). This is the receptor's reaction to its key, even on a lane with no notes: the pressed-receptor mechanism, whose historical names are kept. Mapping: key light image = receptorPressed, color = pressedColor, to which are added keyLightOn (false: the receptor does not react to the key), keyLightOpacity (alpha of the color), keyLightScale (scale of the pressed receptor around its center) and keyLightFadeMs (fade, 0 by default: the instant change from before). During a fade, the normal receptor remains drawn under the pressed image, whose alpha follows the key press; the fade runs on real frame time, like the one for lane lighting. All of this also exists per lane. The editor groups them in the "Key lighting" section of the "Lighting" group.

Renaming and migration of saved values. Lane lighting used to be called light, lightColor, lightSize, and lightOffsetY; keyLightOn once briefly named its switch without ever shipping. Player configurations already saved with the old light* names (playfield, lanes, and overrides) are read once under the new stageLight* names (deserialization aliases, one-time migration of saved data) and rewritten under these new names on the next save. There are no other aliases: skin and map scripts must use stageLight*, and the old keyLightOn is not migrated (it now refers to the key light).

hitLightOn. all (default, behavior of other skins) plays the flash on every non-Miss judgement in the lane. holds plays it only for a hit hold head (non-Miss): never for a regular note, never on a Miss, and not on release either, even a successful one (head only). The stage triggers it when a hold in the lane starts being held, the state that already drives the hold light, so regardless of the judgement's hold model (with single-judgement models, the head emits no judgement). Limitation: a hold started and released between two frames does not display the flash. It is set like the other values (skin < map < player, live, editor "Lighting" group). off draws no hit flash at all, whatever the skin: the player can thus disable the blue flash without touching the hold light (loop, set by holdLight), the lane lighting (stageLight), or the key light, which keep their own fields.

Hit flash and hold light. hitLight and holdLight are frame-by-frame animations, on all lanes (they do not exist per lane, unlike stageLight). A path pattern carries the {n} token, replaced by 0, 1, 2… for each frame (fx/spark-{n}.png); …Frames gives the count (1 by default, 32 at most): a list of images in the editor would require a new field type, whereas the pattern fits in a text field and a number, and can also be set for a player skin (player:boom-{n}.png). A single file is enough for a one-frame animation (…Frames at 1, no token). Each frame is a PNG in the skin folder; the pattern is validated in Rust (safe path, .png, {n} required beyond one frame), with at most 256 distinct images per layer. The flash plays once from its frame 0 at …Fps frames per second, restarts on every new non-Miss judgement in the lane, then disappears; the hold light loops as long as a hold in the lane is held and stops on release or when the hold is lost. Time is real frame time (like the fade of stageLightFadeMs: the rate depends on neither the rate nor the clock, frozen in the editor), with no allocation or per-frame script. The flash is driven by the judgements the stage already receives for stage.* (lane, Miss or not) and the hold light by the game's hold state: none of this touches judgement, score, or replays. The images are centered on the lane's receptor line (including the …OffsetY offset, positive in the notes' direction), above the receptors and the lane lighting, below the notes, flipped like stageLight when notes scroll up (authored for notes that scroll down). Width: 2 lanes if …Size.width is 0, height according to the proportions of the current image. Layers are resolved by value (skin script < map script < player configuration, live in the editor, "Lighting" group). The pattern, taken from the highest level whose first image exists, takes as many consecutive images as it finds up to the requested number (the highest level that yields one, otherwise 1): a missing image is reported and shortens the animation. Blending is the engine's normal alpha blending (a single instance batch, no dedicated shader): additive blending does not apply here, and the default flash images (light cyan, alpha up to 254) display correctly on the dark background. Texture filtering is a skin setting, textureFilter: linear (the default) samples bilinearly, so scaled-up or scaled-down sprites stay smooth; nearest keeps pixels sharp (pixel art skins). The atlas replicates the edge of each image into a 2 px gutter of its own (fully transparent pixels take the color of their opaque neighbors): no neighbor bleeds over and no dark fringe appears at the edge. No mipmaps: bilinear is fine down to a reduction of about 2× (images are scaled down to 640 px at most). The player changes it in the skin editor ("Rendering" group), live; native font rendering remains separate.

Engine default appearance (values absent; the default skin defines all of its own): the lanes take the left, down, up, right arrows of the default skin in turn (one each in 4K, repeated beyond that), each with the key of its arrow (keys/<arrow>.png and keys/<arrow>-d.png), the body notes/body.png and the cap notes/tail-cap.png. These keys are tall (198 × 566 px, arrow in the center): without the skin's receptorSize they are stretched into the 0.0775 square of the engine's default appearance, so you must give a height of 2.86 times the width, as skins/default/skin.ts does. The lane color, historically a linear value, is the 8-bit rounding #454d61 (difference < 0.002).

Resolution and errors

Each value of a lane is resolved in this order: lanes[lane], the playfield.set field, then the engine's default appearance. The map script, if there is one, draws a layer on top: each value it provides takes precedence over the skin's. Nothing invalid prevents the game from being played:

  • number out of bounds: clamped into the bounds; not finite: default value;
  • invalid color or path: next value in the chain;
  • image missing, unreadable, not a PNG, larger than 4 MiB or larger than 2048 px on a side: no image (white rectangle);
  • extra lanes in lanes: ignored;
  • script in error, invalid defineSkin or setup over budget: the first available skin is played in its place (with no skin at all, the engine's default values); if the mods thread does not respond within 1 s, the game starts with the engine's default appearance.

Every problem is reported: a message in the mods log during setup, an error banner at launch and a list on the Skins page (the skin in use shows the problems from its last launch).

Player layer

On top of the skin and the map script, the player customizes each skin separately (skin editor, the skinConfig[skinId] setting in the settings, see Per-skin player configuration): the whole playfield, lane by lane, and the HUD widgets. Each value that is present takes precedence over the skin's and the map's, including any running playfield.update; the others follow the skin. Out-of-bounds values are clamped into the bounds, non-finite ones removed. The layer is applied at every game launch and live during editing: the editing session (Command::EditLayout) plays the editing demo frozen at 2 s (Song::edit_demo, reserved for editing sessions: simple notes and holds of different lengths, including one whose head is on the receptor line, one whose body crosses it and one whose end lands exactly on it, so you can see and adjust body, head, hold end (at least three lanes have one on screen), hold width, noteOffsetY), without judgement, score or replay, until Esc. A lights preview, enabled at the start of the session (overlayEditPreview { lights }, state returned in editLayout.previewLights), has the rendering engine draw, without judging anything, the key light (pressed receptors) and the lane lighting on all lanes, the hold light on the lanes where a hold crosses the receptor line, and the hit flash in a loop (restarted every 1.2 s, according to hitLightOn: all all lanes, holds those that have a hold, off none; keyLightOn and stageLightOn apply); when disabled, the playfield stays clean.

Changing the playfield during play

From its event handlers (ctx.on("game.judgement", …), game.tick, game.pause…, registered in setup), a skin changes any playfield field, including all colors and all images:

setup(play: PlayContext) {
  ctx.on("game.judgement", (judgement) => {
    lanes.set({ column: judgement.column, lane: { receptorColor: judgement.tier.color },
      transitionMs: 0 });
    if (judgement.combo % 100 === 0) {
      playfield.update({ patch: { note: "notes/gold.png", laneWidth: 0.09 },
        transitionMs: 300, easing: "easeOut" });
    }
  });
}
  • playfield.update({ patch, transitionMs?, easing? }): patch is a partial PlayfieldSpec (the fields given change, lanes included). lanes.set({ column, lane, transitionMs?, easing? }) does the same for a single column. transitionMs ranges from 0 to 10,000 (absent or 0: immediate); easing is linear (default), easeIn, easeOut or easeInOut.
  • Numbers and colors go from the displayed value to the new one during the transition; images, anchoring and scroll direction change immediately. Each value has its own transition: a change only interrupts those of the values it modifies. The transition follows the song clock: it stops during a pause.
  • Preloaded images: only the images loaded at launch can be displayed: those named by the playfield.set in setup and those declared by defineSkin({ images: [...] }) (64 at most). An image that is not loaded raises an error in the script; the renderer never decodes anything.
  • Each call is validated (bounds, colors, paths, column < 32); an error is raised in the script. The number of columns does not change and judgement is not affected: these changes are visual.
  • Changes made in the same step of the mods thread (a batch of events) that have the same transition are merged into a single PlayfieldPatch (the last fields win); those with different transitions stay separate, in order (8 at most per step, beyond that they join the last one). They are sent through a bounded queue of 64 that the mods thread fills without ever waiting; if it is full, the changes are kept for the next step.
  • The render thread empties the queue at the start of each frame, resolves the new playfield with the images already in the atlas (no texture upload: tints are shader multiplications, the background tint is a 16-byte uniform) and animates the transition frame by frame. With no change or transition, a frame reads one queue slot and allocates nothing.
  • The mods' game.playfield() entity remains the launch layout.

Native drawing: stage.*

A skin that declares permissions: ["stage"] in defineSkin can also draw in the native renderer with stage.*, down to the frame like the notes: sprites, rectangles, texts, particle emitters and triggers evaluated by the render thread (reference: modding). Without the permission, every stage.* call raises an error. A sprite or an emitter can only name an image declared in defineSkin({ images }); these images are decoded along with those of the playfield, in the same atlas. Like the rest of the script, the skin's elements start from scratch at each map launch.

Loading without slowing the game down

map launch (main thread)
  └─ helper thread:
       queues of the play's changes: skin::patch_channel(), one for the
       skin, one for the map script
       prepare_play(PlayContext, emitters) ──> mods thread: skin.ts reloaded,
         setup(play), then the map script (see map-scripts.md)
       waits for the PlayfieldSpec and the image list (1 s at most)
       skin::prepare: validation, PNG decoding (skin, map script,
         mods' stage images), a single atlas
  └─ main thread: RenderCommand::Skin { prepared, patches,
       chart_patches } then Start
render thread: one texture upload before the first frame;
               on each frame: received changes, transitions, drawing

Images (those of the playfield.set and the declared images) are decoded and then downscaled (keeping their proportions) to at most 640 px per side and packed into an atlas at most 2048 px wide and 4096 px high; at most 256 images per layer (skin, map script), and an image that does not fit is ignored (white rectangle). The render thread only receives the ready atlas and the resolved layout: it neither reads nor decodes anything, and a frame with no change allocates nothing.

Converting an osu! skin

The mods/skin-converter mod adds a "Convert an osu! skin" card to the Skins page: the player chooses an osu!mania skin folder or an .osk, the card shows the 4K to 7K layouts that will be created, what Prism cannot do (distinct hold head, NoteBodyStyle ≠ 0, column lines, judgement, combo and score images, sounds…) and the weight of the skin; "Create the skin" writes an ordinary folder into <data>/skins/ (never replacing an existing skin), then "Open in the skin editor". The values follow those of the default skin converted by hand (ColumnWidth → laneWidth, ColumnSpacing → laneGap, HitPosition → receptorY at the middle of the notes, KeyImage aligned with the place osu! gives them, StageLight/LightingN/LightingL → lighting, Colour{N} → laneColor, ScorePosition and ComboPosition → HUD); the known differences are listed in the golden test crates/modding/tests/skin_converter.rs. A key count with no converted section plays with the closest converted layout. Design: convertisseur-skin-osu.md.

Managing skins

Skins page (Esc menu): a list showing the preview (the skin's preview.png, otherwise its left.png), name, version and author declared by defineSkin, the skin in use, and any issues; buttons Use, Export, Uninstall, Import…, Open folder, Reload. Changing skin restarts the mod host (outside of a play session); it takes effect from the next launch.

  • Setting skin (folder name, default by default), saved with the other settings and validated by Rust: an invalid name becomes default; a missing folder is reported and the first available skin is used. Each skin has its own player configuration (skinConfig[<id>]). prism.exe --skin <id> (benchmark, smoke test) selects any skin.
  • Uninstall deletes the skin's folder, including a game-provided skin from skins\ next to prism.exe if it is writable (otherwise an error names the path).
  • Export writes <id>.zip to a chosen folder (hidden files excluded; every other file must be .ts, .png, .ttf or .otf).
  • Import installs a chosen .zip as a new folder, never replacing an existing skin: named after the archive's root folder (or the file), suffixed -2, -3… if needed. The whole archive is verified before anything is written: safe relative paths (no zip-slip, absolute path, \, or reserved name), no symbolic links or encryption, allowed extensions, case-colliding duplicates rejected, 64 MiB per archive, 512 files, 32 MiB per file and 64 MiB in total, the actual size of each file checked during extraction; skin.ts required at the root (or in the single root folder). Extraction happens in a hidden folder followed by a rename.
  • Reload re-reads the list; a skin's files are re-read on every map launch.

prism.exe --bench-gameplay 10 --skin <id> and --smoke-test --skin <id> play with the skin <id> (the UI settings arrive after the benchmark starts).

Contract with the UI

  • Event { type: "skins", dir, skins: [{ id, name, author, version, preview, errors }], selected, errors }: selected is the skin for the next launch, errors the folder entries that are not skins, preview a path /skins/<id>/<file> served by the prism protocol.
  • { type: "skinOperation", action: "import" | "export", id, path } after a successful operation; failures arrive as error.
  • Commands selectSkin { id }, reloadSkins, openSkinsFolder, importSkin, exportSkin { id }.

Example: pink notes

  1. Copy the skins\default folder under another name (skins\rose) into the player's skins folder, change id and name in its skin.ts, then Reload on the Skins page.
  2. In rose\skin.ts, change noteColor: "#ffffff" to noteColor: "#ff4fa3" (or replace notes/left.png with another image).
  3. Use this skin and launch a map.

Per-skin player configuration

Player settings are stored per skin (Settings.skinConfig[skinId], 64 skins at most): playfield (SkinPlayfieldConfig) and elements (options of the HUD elements that the skin places). The replacement of the old global playfieldLayout setting is read only once: it is moved into the selected skin's configuration if that configuration has no playfield, otherwise it is discarded.

  • Playfield layers, the highest wins value by value, live and on top of any transitions in progress: skin < map script < player configuration. It covers x, y, zoom, rotation, lane width/spacing/height, receptor line, scroll direction, sizes (notes, receptors, hold ends), border, colors, background tint and, lane by lane (lanes, columns 0..31), offsetX, offsetY, width, sizes, colors and images. A lane's offsets move its background, receptor, notes and holds, never the judgement.
  • Options of an element, a single order: declared defaults < the skin's configure() < skinConfig[skin].elements (editor); a global value never overrides the editor. Mods' own settings (elementOptions, player: true options) apply only to an element that the editor cannot place, and remain below skinConfig.
  • Images: a reference is a PNG from the skin's folder or player:<file> (images imported by the player into <data>/skins-config/<skin>/images/, PNG/JPEG/WebP, 4 MB and 2048 px at most, 64 per skin; the atlas downsizes them to 640 px). The player configuration is a separate layer: it does not modify the skin's files.
  • The in-game editor (overlayEditSkin, overlayEditElement, overlayImportSkinImage) works on a copy of the active skin's configuration; each validated gesture (commit) saves it and emits skinConfigChanged.

Customizing a skin in-game

Skins page → Customize skin (card of the current skin) opens the skin editor: the frozen demo runs beneath the overlay, and a click on the playfield, on a lane, or on a HUD widget selects it, with the inspector offering what can be adjusted at that spot. The field catalog comes from the host (fields, groups placement / size / color / image / lighting / judgement line / rendering, bounds and steps): no bound is written twice in the interface. Each field carries a section (stable camelCase identifier: position, lanes, scroll, notes, receptors, holds, background, keyLight, stageLight, hitLight, holdLight, line, texture): the interface displays a small heading per section in a tab (skin_editor.section_<id>), with the fields of a section listed together in catalog order.

  • Playfield: position (drag), zoom, rotation, width, gap and height of the lanes, receptor line, scroll direction, sizes of the notes, receptors, and holds, border, colors and tint of the background.
  • Lane by lane: each lane can be freely placed (offsetX, offsetY in screen heights; the inspector enters them in window pixels, "Center X / Y": one at 0 px, another at 100 px, a third at 1250 px), resized, and has its own colors and images.
  • Images: note, receptor, pressed receptor, hold body and tail, lane background, and playfield background are chosen from the skin's images or imported ones (player:<fichier>), or imported from disk.
  • HUD widgets: these are mods; each one can be selected, moved, resized, and hidden, per skin (skinConfig[skin].elements).

A customized skin is recognizable on the Skins page (badge, number of values, lanes, widgets, and imported images); Reset customization removes its configuration. All the options of a HUD widget (accuracy decimals, visibility, fonts, colors, animations…) are set here, skin by skin: the editor is the only place. Settings → Mods now shows only the player: true options of a mod whose element has no place (neither a numeric x nor y: the editor cannot place it); the category disappears when there are none. The old global values of an element that the editor places are carried over once into the configuration of the active skin and of already configured skins, without ever overwriting a skin value.

Missing skin features

Proposal, none of this is implemented. Comparison with the osu!mania skin.ini settings (keys verified on the osu! wiki), the StepMania/Etterna noteskins, and the Quaver skin.ini (the latter two sources from memory, to be confirmed before coding). Sorted by decreasing interest; effort: S (a few hours), M (a day), L (several days); risk: performance or format regression.

# Feature Who has it Value Effort Risk
1 Notes colored by subdivision (1/4 red, 1/8 blue, 1/12 purple, 1/16 yellow…): tint or image per subdivision, computed from the game-core::barlines schedule StepMania/Etterna (noteskins by quantization), Quaver (ColorObjectsBySnapDistance) very high: reading the rhythm, requested by all StepMania players M: one subdivision per note computed at load (outside rendering), one tint per subdivision in the configuration; images per subdivision in L medium: accuracy across BPM changes, stops not taken into account, one more table per play
2 Lane separators, borders, and stage fill (individual width and color) osu! (ColumnLineWidth, ColourColumnLine, StageLeft/StageRight/StageBottom) high: one of the most visible elements of an osu! skin S: thin quads between the lanes, under the notes low
3 Position and size of the combo and judgement osu! (ComboPosition, ScorePosition), Quaver (ComboPosY, JudgementBurstPosY) medium S: these are mod widgets; make them mod options, not skin fields low
4 Hit light variants: image per judgement tier, per lane, own height (LightPosition) osu! (LightPosition, LightingN/LightingL), Quaver (HitLightingY, width/height) medium: we already have hitLight/holdLight, the tier color and the offset S low
5 Images by note type: chord (several notes at the same instant), special lane osu! (SpecialStyle, NoteImage# images), StepMania (jump) medium M: group simultaneous notes at load medium
6 Long note body styles: stretched (current), repeated from the top or bottom osu! (NoteBodyStyle), StepMania (stretched or repeated body, caps) medium M: the shader has no atlas repetition, so quads per tile are needed (bounded) medium: number of quads
7 Animated notes and receptors (image sequence) osu! (NoteImage#@N), StepMania, Quaver (AnimationFrameRate) medium: the lights already are, the notes are not M: one image index per note on every frame, in the hot path medium: performance, to be measured with --bench-gameplay
8 Per-lane key image under or over the notes osu! (KeyImage#, KeysUnderNotes), Quaver (ReceptorsOverHitObjects) medium S to M: one layer per lane and a draw order low
9 Lane cover on the receptor side (lift / "hidden") and in-game adjustment StepMania/Etterna readers (appearance), osu! (lazer: cover) medium: the current laneCover only covers the spawn side S low
10 Flip note images when scrolling upward (by type: head, body, tail) osu! (NoteFlipWhenUpsideDown#H/L/T), Quaver (FlipNoteImagesOnUpscroll) low to medium: we already have holdEndFlip S low
11 Exact measures for StepMania: #TIMESIGNATURES, stops, and warps as timing points in the import — (data, not skin) medium for the measure lines of .sm files M: extend the ROX format (vendor/Rhythm-Open-Exchange) medium: affects import and replays

osu! also has ColourBarline/BarlineHeight (measure lines, without images), ColourJudgementLine, and JudgementLine: already covered by measureLines* and judgementLine*.

What should be a mod, and what stays in the engine

Rule: engine (native) by default; a mod only when it is a genuine optional overlay, built from existing primitives, and worth making removable. Measure lines were first considered as a mod (a scene primitive plus a chart query): rejected, because they rely on the renderer's exact scroll time and on a schedule computed at launch, which is engine territory. The following stay in the engine, unchanged: lane cover (laneCover), holdMissedStyle, the judgement line and its anchoring, lights (stage, key, hit, hold), hold end image, per-lane colors, and measure and beat lines. HUD widgets (judgement, accuracy, combo, counters, progress bar, FPS, ghost…) are already mods. The rating picker (RatingPicker) is part of the interface, not the skin. Nothing needs to be migrated.

Source in the game repository: docs/skins.md