On this page

← All documentation

Concepts: how a mod works

Lifecycle, threads and budgets, events, permissions, errors, determinism and what is forbidden.

Every claim refers back to the code (crates/modding/src/…) or to TypeScript mods: the detailed guide. Anything that could not be verified is marked [TO VERIFY].

Three kinds of scripts, one API

Kind File Declaration Role
Mod mods/<folder>/main.ts (or index.ts) defineMod HUD, judgement sets, scroll speeds, difficulty ratings, filters, keys, modifiers
Skin skins/<folder>/skin.ts defineSkin Positions the native playfield and configures the HUD elements that mods provide
Map script script.ts or <chart>.script.ts in the map's folder defineChart Animates the playfield of that map (visual only)

A mod cannot call playfield.* or lanes.set (reserved for skins and map scripts); a map script cannot use hud.* or storage.* (api.rs: playfield_call, for_mods_and_skins). Full table: Availability by script kind.

Lifecycle

  1. Loading: the top level of the file runs. Only defineMod (or defineSkin/defineChart), log.*, and ctx.on work; any other host call throws … is not available while the mod loads: call it from `setup` or an event handler. Loading is also used by the installer to read the declaration without running setup.
  2. Declaration: defineMod must be called exactly once, at the top level. A second call, an invalid declaration, or an id that is already taken makes the mod fail, even if the exception is caught.
  3. setup(mod): called once, after the setup of the packages listed in uses. This is the only place where *.register calls are allowed: they close after all mods have loaded (… is only available in a mod's setup).
  4. Events: the mod receives game.*, library.*, controls.action, elements.configure, etc., and can call game.*, hud.*, storage.*…

Mod order: providers before their dependents (uses), otherwise loadOrder (0 to 10000, default 1000) and then id. This order also decides the stacking of HUD layers and the delivery of events.

Mods load in the background: the game does not wait. A mod disabled by the player is declared (its metadata is read) but its setup does not run. A skin and a map script are reloaded from scratch every time a map is launched: nothing persists from one play to the next, except storage for mods.

Threads and budgets

  • All the code of mods, the skin, and map scripts runs on a single mods thread, never on the input, render, audio, or window threads. The game threads send events through a bounded queue (1024) without ever waiting for mods; if it is full, the event is dropped. Consequence: an event can be missed. Do not build critical state on receiving every game.judgement; re-read game.player() or the totals (counts, combo, accuracy carried by each event).
  • Each mod has its own Rust-TS engine (QuickJS): 16 MiB of memory, 1 MiB of stack, 30 ms of execution per load, setup, event, or tick (Budgets).
  • A mod is disabled after 3 consecutive failures (uncaught exception or budget overrun) or 3 budget overruns in total, until the next host startup.
  • Values that change every frame must not go through the script: use bindings (bind, fill, showWhen, animate), which the overlay resolves on its own, and the stage.* animations/triggers that the renderer evaluates. An efficient script only runs a few times per second.

Events

Event Cadence
game.songStart / game.songEnd once per play
game.judgement for each judged note (best-effort)
game.tick 30 Hz while the chart is playing, not paused; skips a tick when late
game.beat at the exact time of each beat of the chart; a single beat (the last one) if late
game.pause / game.resume transitions
game.playfieldChange, game.settingsChange changes
library.ready, library.chartAdd, library.chartsAdded menu / library
leaderboard.result, skinImport.opened, skinImport.staged, skinImport.created, skinImport.action responses to the requesting mod
ratings.progress, metron.performanceResult, ui.action responses to the requesting mod
controls.action key of a declared action (may be dropped under load: not for a scoring state)
elements.configure another package (or the player) configures an element of the mod

Payloads: Events.

Dependencies and configurable elements

A mod (or a skin) declares uses: { "autre.mod": "^1" }; a dependency is optional by default (when absent, ctx.has("autre.mod") is false and a note appears on the Mods page). required: true makes it mandatory. At most 16 dependencies, no cycles (dependency cycle: a → b → a).

A mod exposes widgets with provides.elements (typed options: number, integer, boolean, string, color, enum, colors). Other packages place them with ctx.element("<package>/<element>").configure({…}); the provider receives elements.configure. Single order of precedence: declared defaults < the skin's configure() < the player's configuration in the skin editor. Scripts never call each other.

Permissions

Three permissions exist (type Permission = "stage" | "skinImport" | "network"), to be declared in permissions: [...]:

Permission Grants access to Revocation by the player
"stage" stage.* (native rendering) per mod (stageDenied setting); without it every stage.* call throws an error
"network" downloads.register, downloads.registerBridge (download mirrors and sources) per mod (networkDenied setting, based on Mods and downloads from the feature/mod-download-sources branch); the declared hosts are shown to the player
"skinImport" skinImport.* (convert an osu! skin) no revocation setting found in settings.rs [TO VERIFY]; without the permission the call throws "skinImport needs the skinImport permission"

network gives the script no network access: it only allows declaring hosts; the host (crates/downloader) makes all the requests, over HTTPS, to those hosts only, and keeps the player's token. There is no file or DOM permission: those accesses do not exist at all.

Errors you will see

Message (or beginning) Cause
\`x\` requires modding API version 1; this game provides version 2 apiVersion other than 2
another mod already uses the id `…`: the folder `…` loaded first and keeps it duplicate id across two folders
defineMod must be called exactly once second call, or call after a declaration error
defineMod can only be called at the top level of the mod's entry file defineMod called inside setup or a handler
… is not available while the mod loads host call at the top level of the file
… is only available in a mod's setup *.register called after setup
\`playfield.set\` is only available to skins and map scripts playfield.* from a mod
… is not available to map scripts: they only change the playfield hud.*/storage.* from a map script
add \`<paquet>\` to \`uses\` to configure its elements ctx.element(...) on a package missing from uses
TypeError with the reason out-of-range value, unknown field, unreadable color, id too long… (recoverable)
\`version\` must be a semantic version such as 1.0.0 invalid version (1.0 is rejected)
\`id\` must be … id outside a-z 0-9 . - _, uppercase, .., Windows reserved name
requires Y ^1: package Y missing / features using Y disabled missing required / optional dependency
a mod registers at most N … per-mod limit exceeded (8 games, 8 scroll speeds, 16 tables, 16 views, 32 filters, 32 actions, 8 modifiers)

A mod that fails is not loaded and the error appears on the Mods page; the other mods load normally. Loading transpiles without type checking: a type error only shows up with tsc (see README).

Determinism and replay

  • Judgements are native: a script can neither inject inputs, nor modify a judgement, nor a score (gameplay.register only declares a kind known to the host).
  • The tiers of a judgement set are resolved once per parameter combination and then stored as-is in replays, which are rejudged without the mod. tiers(params) must therefore be pure and synchronous. The same goes for the toMs conversions of scroll speeds: the renderer only receives the resolved time.
  • A map script is visual: the replay only stores its BLAKE3 fingerprint.
  • The Hit entries in game.hits are the real native history (including chords and separate releases), not a reconstruction from events.
  • Non-deterministic functions (Math.random, Date): their availability in the engine is not documented in the code that was read [TO VERIFY]; do not use them for content that must stay identical from one play to the next. There are no timers (setTimeout…), no network (the network permission only declares sources), and no file access (Security).

What a mod can NEVER do

  • Access the DOM, the interface's JavaScript, IPC commands, the network (the script never makes a request, even with network), files outside its folder, or timers.
  • Inject keys, modify notes, the judgement, the score, or a replay.
  • Draw raw HTML or CSS, or load a custom shader.
  • Read another mod's storage, or call another mod directly.
  • Load a native library (DLL): out of scope (AGENTS.md).
  • Read any file other than its declared fonts (.ttf/.otf) and images (.png).
  • Dynamic import() (rejected); relative imports and node_modules from the mod's folder are resolved, never outside the folder.
  • Add modifier behaviors: only the kinds known to the host (auto, ghost, mirror, random, noLn, fullLn) exist.

Mods, skins, themes: not to be confused

  • A theme (themes/<id>/) is data (manifest + tokens.css), never code: see Interface themes.
  • A skin is code (skin.ts) that configures the playfield and the elements.
  • A mod provides the widgets, the judgement sets, etc.

Source in the game repository: docs/modding/concepts.md