On this page

← All documentation

Map scripts

Map scripts (defineChart): what they change, budgets, replays.

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.ts applies to all difficulties in the folder;
  • <chart file name>.script.ts applies only to that difficulty and takes precedence over script.ts: Artiste - Titre [Hard].script.ts next to Artiste - 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() (with timing, the tempo points), game.notes(...), game.player(), game.judgements(), game.playfield(), game.settings(), and log.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 (and game.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); index counts beats since the first tempo point, meterBeat is 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 the mods thread (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, mismatched apiVersion, missing declared image, setup failing 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 mods thread 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.

Source in the game repository: docs/map-scripts.md