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 toprism.exe(in development, the repository'sskins/folder, found by walking up from the executable to the folder containingCargo.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 itsskin.inifor 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 (laneWidth0.129 in 4K, no gap),#07090f96lanes, a one-pixel light edge (theStageLeftandStageRightimages), dark arrows (left,down,up,right, pluscenter,upleftanduprightfor odd or 6K layouts) and their keys lit on press, hold body and capped end piece.HitPosition430 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 (ComboPosition240) and last judgement (ScorePosition188) are centered. The archive's@2ximages are used as they are (4K keys, 5Kcenterand 6Kupleft/uprightscaled down to 198 px wide, the other layouts reuse the 4K arrows); the lighting is kept (StageLight:stageLighttinted#aae4ff(ColourLight1);LightingN:hitLight, 8 imagesfx/explosion-{n}.png, played on each hit;LightingL:holdLight, 6 imagesfx/holdlight-{n}.png, atLightFramePerSecond60, on the hit positionLightPosition430, 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 isassets/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 itsskin.ini: 72 px lanes on a virtual screen of 480 (laneWidth 0.15, no gap), judgement line at 440 (receptorY0.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 (ComboPosition130,ScorePosition150). 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! (nostageLightimage). The notes are centered on the judgement line (middle of the note,noteOffsetY0). 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 thedefaultskin ("PRISM // arrow (cap ends)" by april):hold_body.pngis a copy ofnotes/body.png(256 × 32 px) andhold_cap.pnga copy ofnotes/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 (holdWidth0.15), the end piece as wide as the body (holdEndSize0.15 × 0.15, noholdEndOffset), white tints. The archive's heavy images (22 MB), the.psdfiles, the sounds, the cursor and the scores are not included: only 6 PNG files and the script, weighing about 30 KiB. The original archive isassets/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 byPRISM_SKINS_DIR(which only replaces this root of the player's data; the game's skins are still read fromskins\next toprism.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 withoutskin.tsis 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)mergesspec(aPlayfieldSpec) into the playfield description: the fields provided replace those from previous calls, while the others remain.lanes.set({ column, lane })merges aLaneSpecinto the columncolumn(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.setdraws 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 withctx.element("<package>/<element>").configure({ … }), after declaring it inuses(optional) and testing it withctx.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 ofskins/default/skin.ts(and that ofskins/default-circle/skin.ts) serves as a complete example: it creates nohud.*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 takex,y,anchor,size, andvisible(except the bar:width,height,radius…), plus their colors. The default skin shipshitsandmisseswithvisible: false: the player's skin configuration can display them. Positions and sizes that depend on the screen are computed withplay.screenWidthandplay.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 inuses("^1", or{ version: "^1", feature: "…" }to name what is missing without it, as the default skin does), then places and styles it in itssetup:ctx.element("pvng.judgement-display/judgement").configure({ x, y, anchor, size, animation, colors })and…/counts(options in modding). Without the mod,ctx.hasisfalse, 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"touses, then place its element ifctx.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 onpvng.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 (
#TIMESIGNATURESis 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
defineSkinorsetupover 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? }):patchis a partialPlayfieldSpec(the fields given change,lanesincluded).lanes.set({ column, lane, transitionMs?, easing? })does the same for a single column.transitionMsranges from 0 to 10,000 (absent or 0: immediate);easingislinear(default),easeIn,easeOutoreaseInOut.- 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.setinsetupand those declared bydefineSkin({ 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,defaultby default), saved with the other settings and validated by Rust: an invalid name becomesdefault; 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 toprism.exeif it is writable (otherwise an error names the path). - Export writes
<id>.zipto a chosen folder (hidden files excluded; every other file must be.ts,.png,.ttfor.otf). - Import installs a chosen
.zipas 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.tsrequired 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 }:selectedis the skin for the next launch,errorsthe folder entries that are not skins,previewa path/skins/<id>/<file>served by theprismprotocol. { type: "skinOperation", action: "import" | "export", id, path }after a successful operation; failures arrive aserror.- Commands
selectSkin { id },reloadSkins,openSkinsFolder,importSkin,exportSkin { id }.
Example: pink notes
- Copy the
skins\defaultfolder under another name (skins\rose) into the player's skins folder, changeidandnamein itsskin.ts, then Reload on the Skins page. - In
rose\skin.ts, changenoteColor: "#ffffff"tonoteColor: "#ff4fa3"(or replacenotes/left.pngwith another image). - 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: trueoptions) apply only to an element that the editor cannot place, and remain belowskinConfig. - 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 emitsskinConfigChanged.
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,offsetYin 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.