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
- Loading: the top level of the file runs. Only
defineMod(ordefineSkin/defineChart),log.*, andctx.onwork; 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 runningsetup. - Declaration:
defineModmust 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. setup(mod): called once, after thesetupof the packages listed inuses. This is the only place where*.registercalls are allowed: they close after all mods have loaded (… is only available in a mod's setup).- Events: the mod receives
game.*,library.*,controls.action,elements.configure, etc., and can callgame.*,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
modsthread, 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 everygame.judgement; re-readgame.player()or the totals (counts,combo,accuracycarried 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 thestage.*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.registeronly 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 thetoMsconversions of scroll speeds: the renderer only receives the resolved time. - A map script is visual: the replay only stores its BLAKE3 fingerprint.
- The
Hitentries ingame.hitsare 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 (thenetworkpermission 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 andnode_modulesfrom 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.