A map script (defineChart) animates a map's playfield while it plays, like ITG modcharts: the playfield moves, rotates, changes scale, its lanes shift, and its colors and images change to the rhythm of the song. The script belongs to the map: there is no global motion setting, and a map without a script keeps the skin's playfield. Map scripts are distinct from mods, which the player installs
for all of their plays, and from skins, which describe the base appearance
of the playfield.
Everything is visual: judgement and score never depend on the script; the replay only records its fingerprint (see Replays).
Files
In a map's folder, next to its charts and its audio:
script.tsapplies to all difficulties in the folder;<chart file name>.script.tsapplies only to that difficulty and takes precedence overscript.ts:Artiste - Titre [Hard].script.tsnext toArtiste - Titre [Hard].osu.
The file is re-read every time the map is launched: you can edit it without recompiling or restarting the game. It is at most 1 MiB.
The player can disable all map scripts: Settings → Game →
Allow map scripts (setting allowMapScripts, enabled by default,
enforced on the Rust side). When disabled, no script is read and the play runs
with the skin alone.
Declaration
export default defineChart({
apiVersion: 2,
// Images from the map's folder that the handlers display during the
// play (64 at most), in addition to those that `setup` names.
images: ["arrows/flash.png"],
setup(play: PlayContext) {
playfield.set({ zoom: 0.9 });
ctx.on("game.beat", (beat: Beat) => { /* … */ });
},
});
defineChart is called exactly once, at the top level of the file, with
apiVersion, setup and, if needed, images and permissions (["stage"],
see below). A map script has no identifier: it is identified by its
map. setup(play) receives the same PlayContext as a skin's setup
(mode, layout, number of lanes, window size, song, judgement
tiers); name its parameter play, not ctx, which would shadow ctx.on.
The script runs in the same Rust-TS engine as the skin and the mods, on the
mods thread, freshly loaded at each launch, after the skin. It has
access to:
playfield.set,playfield.update,lanes.set: the skins' calls, same fields, same bounds, same transitions (see skins);game.song()(withtiming, the tempo points),game.notes(...),game.player(),game.judgements(),game.playfield(),game.settings(), andlog.info/log.warn;- the events
game.songStart,game.beat,game.tick,game.judgement,game.pause,game.resume,game.songEnd,game.playfieldChange,game.settingsChange.
It has no HUD (hud.* throws an error), no storage (storage.* throws an
error), no network, and no file access other than the images in its
folder, loaded before the play.
With permissions: ["stage"], it also draws in the native rendering
(stage.*, see modding): its elements
placed on the playfield follow its movements, rotations and zooms. Its
sprites and emitters only name images declared in
defineChart({ images }).
Without the permission, every stage.* call throws an error. The setting
"Allow map scripts" also turns off this drawing, since the script
is then not read.
What the script changes
The script describes a layer of the playfield, drawn on top of the skin's:
every value it provides takes precedence over the skin's for as long as it
provides it; the others remain the skin's. For a lane, the chain is:
the script's lane (lanes[i]), its common values, the skin's lane,
the skin's common values, then the engine's default appearance. A lane
width (laneWidth) given by the script takes precedence over the skin's total width
(width).
All playfield fields are available (positions, sizes, colors, images), and three are mainly used for effects:
| Field | Bounds | Default | Role |
|---|---|---|---|
rotation |
−3600 to 3600 | 0 | rotation of the entire playfield around the center of the lanes' box, in degrees clockwise; notes follow their lanes |
zoom |
0 to 4 | 1 | scale of the entire playfield around the same center |
lanes[i].offsetX |
−2 to 2 | 0 | horizontal offset of a lane (background, receptor, notes, holds), in screen heights, to the right if positive |
These three fields also exist for skins. The renderer applies them in the
shader: each quad rotates around its own center, placed where the
playfield's rotation and scale bring it. The mods' game.playfield() entity
remains the layout at launch (lane offsets included, without
rotation or zoom).
Transitions
playfield.update({ patch, transitionMs, easing }) and
lanes.set({ column, lane, transitionMs, easing }) change values
immediately or in a transition (0 to 10,000 ms, linear, easeIn, easeOut,
easeInOut). The render thread animates each value separately based on
the song clock: a 100 ms color flash does not interrupt a
2 s sway started earlier, whether it comes from the script or the skin. A
pause stops transitions.
Changes from the same handler (one step of the mods thread) that have the
same transition are merged; those with different transitions
remain separate and apply in order. You can therefore light up a
color and then fade it back in the same handler:
playfield.update({ patch: { laneColor: "#8a5cf6" } });
playfield.update({ patch: { laneColor: "#454d61" }, transitionMs: 200, easing: "easeOut" });
Tempo and time
play.song.timing(andgame.song()?.timing): the chart's tempo points, sorted,{ timeUs, bpm, beatUs, meter }(1024 at most).game.beat:{ index, timeUs, bpm, meterBeat }on every beat, at its exact time on the song clock (not rounded to ticks);indexcounts beats since the first tempo point,meterBeatis 0 on the first beat of the measure. Beats only arrive while the clock is advancing; after a pause, the next one resumes.game.tick: the song time, at the tick rate of themodsthread (30 Hz).
An efficient script starts transitions on beats and lets the renderer animate them: it only runs a few times per second.
Images
A script's images are PNG files from its own folder: those that setup
names and those that defineChart({ images }) declares. All of them are decoded
before the play, subject to the skin limits (4 MiB, 2048 px per side, scaled
down to 640 px, at most 256 images for the layer); a declared image that is
missing or is not a PNG makes the script fail. During play, a change
that names an image that is not loaded raises an error in the script. A script
image that does not fit in the atlas gives way to the skin's one.
Budgets and errors
The script is treated like a downloaded mod: 30 ms of execution per call
(loading, setup, each handler), 16 MiB of QuickJS memory,
disabled after 3 budget overruns or 3 errors in a row.
- Unreadable script, missing or invalid
defineChart, mismatchedapiVersion, missing declared image,setupfailing or over budget: the error is reported (banner at launch, mod log) and the play proceeds with the skin alone. - Script disabled during play: its layer disappears, the skin's playfield remains.
- Out-of-range or invalid value: clamped to the bounds or replaced by the skin's value, and reported, as for a skin.
- With no response from the
modsthread within 1 s, the play starts with the engine's default appearance and without a script.
Replays
The replay records mapScript: the BLAKE3 hash (hexadecimal) of the
source of the script that drew the play, null without a script or when it could
not start. Judgement does not depend on the script: a replay plays back
identically with or without it.
Loading without slowing the game down
map launch (main thread)
└─ game context; chart allowed if the setting permits
└─ helper thread:
map script: <chart>.script.ts, otherwise script.ts
two change queues: skin and script (skin::patch_channel)
prepare_play(context, skin queue, script + its queue)
──> mods thread: skin.ts reloaded, setup(play), then the script
reloaded, setup(play); reply: the two PlayfieldSpec
skin::prepare(skin layer, script layer, mods' stage images):
validation, PNG decoding, 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: changes received from both layers,
transitions, drawing (nothing allocated without changes)
Example
examples/map-scripts/first-light/script.ts: the playfield sways from
side to side while tilting, one sway every two measures, and the
lanes light up on every beat, stronger on the first beat of the measure.
Copy it next to a chart under the name script.ts to try it out.
ctx.on("game.beat", (beat: Beat) => {
const beatMs = 60000 / beat.bpm;
const downbeat = beat.meterBeat === 0;
if (downbeat && downbeats++ % SWING_MEASURES === 0) {
side = -side;
const swingMs = SWING_MEASURES * meterAt(play.song.timing, beat.timeUs) * beatMs;
playfield.update({
patch: { x: 0.5 + side * SWAY, rotation: side * LEAN },
transitionMs: Math.min(10000, swingMs),
easing: "easeInOut",
});
}
// A glow at once, then back by the next beat: two paces, two transitions.
playfield.update({ patch: { laneColor: downbeat ? DOWNBEAT : BEAT } });
playfield.update({ patch: { laneColor: LANE }, transitionMs: 0.9 * beatMs, easing: "easeIn" });
});
prism.exe --bench-gameplay 10 --map <path to the .osu> runs the map's
script like a real play and prints PRISM_BENCH map script: …
(path, state, hash, problems); prism.exe --smoke-test does the same
(PRISM_SMOKE map script: …) for the map selected at startup.