On this page

← All documentation

Cookbook

Complete recipes type-checked against the SDK: HUD, native rendering, settings, judgement sets, difficulty ratings.

Seventeen complete recipes, ready to copy as-is into a main.ts (mod), skin.ts or script.ts. Every ```ts block is checked with tsc --strict against mods/sdk/modding.d.ts by node scripts/check-modding-docs.mjs; the values and patterns are taken from the bundled mods (mods/*/main.ts, examples/). The mod ids below (cookbook.*) are free-form: change them. The path in the /// <reference …> line is that of a mod placed in the repository's mods/ folder; elsewhere, copy mods/sdk/modding.d.ts next to it.

A reminder of the rules that matter for every recipe: apiVersion: 2; one declaration per file; *.register calls go in setup; HUD lengths are fractions of the screen height; no API exists outside Mod API reference (version 2).

1. A judgement set (judgements.register)

A set only describes its tiers: the game judges natively. Here two parameters derive the windows. Inspired by mods/osu/main.ts and examples/mods/judgement-colors.

/// <reference path="../sdk/modding.d.ts" />

export default defineMod({
  id: "cookbook.tight-judge",
  name: "Tight judge",
  version: "1.0.0",
  apiVersion: 2,
  setup() {
    judgements.register({
      id: "tight",
      name: "Tight",
      params: { width: { label: "Width", short: "W", min: 1, max: 3, step: 1, default: 2 } },
      accuracy: "weights",
      holds: "head",
      // Called once per value of `width` right after setup: pure and synchronous.
      tiers: ({ width }) => [
        { id: "perfect", name: "Perfect", color: "#8cecff", windowMs: 10 * width, weight: 1 },
        { id: "great", name: "Great", color: "#93e69b", windowMs: 25 * width, weight: 0.7 },
        { id: "good", name: "Good", color: "#ffe38a", windowMs: 45 * width, weight: 0.3, breaksCombo: true },
        // The last tier is the Miss; its window is the widest.
        { id: "miss", name: "Miss", color: "#ff7185", windowMs: 70 * width, weight: 0, breaksCombo: true },
      ],
    });
  },
});

Pitfalls: windows increase from the tightest to the widest; tier ids use a-z 0-9 -; the set appears as <mod id>/tight in Settings → Judgement.

2. A scroll speed provider (scrollSpeed.register)

toMs returns the scroll time in milliseconds (positive, finite). context.travel is the reference distance in screen heights. Modeled on mods/scroll-osu/main.ts.

/// <reference path="../sdk/modding.d.ts" />

export default defineMod({
  id: "cookbook.beat-scroll",
  name: "Beat scroll",
  version: "1.0.0",
  apiVersion: 2,
  setup() {
    scrollSpeed.register({
      id: "beats",
      name: "Beats",
      param: { label: "Beats", short: "B", min: 1, max: 8, step: 1, default: 4 },
      toMs: (beats, context) => beats * 150 * context.travel / 0.8,
    });
  },
});

3. A HUD element with bindings (hud.* + bind)

The combo and accuracy update without going through the script: the mod only places the nodes when a play starts.

/// <reference path="../sdk/modding.d.ts" />

export default defineMod({
  id: "cookbook.hello-hud",
  name: "Hello HUD",
  version: "1.0.0",
  apiVersion: 2,
  setup() {
    ctx.on("game.songStart", () => {
      hud.group({
        id: "panel",
        flow: { direction: "column", align: "end", gap: 0.004 },
        layout: { x: 0.98, y: 0.5, anchor: "topRight" },
      });
      hud.text({ id: "combo", parent: "panel", text: "", bind: { kind: "combo" },
        animate: { on: "hit", kind: "pop", durationMs: 150 }, style: { size: 0.06, weight: 700 } });
      hud.text({ id: "acc", parent: "panel", text: "", bind: { kind: "accuracy", decimals: 2 },
        style: { size: 0.03, color: "#c8d2ff" } });
    });
    ctx.on("game.songEnd", () => hud.clear());
  },
});

4. A hit bar-style visualization (game.hits, game.tick)

Draws the latest timing offsets on a bar; re-reads the native history only when hitCount changes. A reduced version of mods/hit-bar/main.ts (which adds window bands, options and pause handling).

/// <reference path="../sdk/modding.d.ts" />

const TICKS = 20;
let tiers: TierInfo[] = [];
let extent = 1;
let lastCount = -1;

const place = (offsetMs: number) => Math.max(0, Math.min(1, 0.5 + offsetMs / (2 * extent)));

function build(config: JudgementConfig) {
  tiers = config.tiers;
  const hitWindows = tiers.flatMap((tier) => (tier.earlyMs != null && tier.lateMs != null ? [tier.earlyMs, tier.lateMs] : []));
  extent = Math.max(1, ...hitWindows);
  hud.clear();
  lastCount = -1;
  hud.group({ id: "bar", layout: { x: 0.5, y: 0.45, width: 0.4, height: 0.04, anchor: "top" } });
  hud.box({ id: "track", parent: "bar", layout: { y: 0.4, width: 0.4, height: 0.012 }, style: { background: "#080b12b3" } });
  for (let index = 0; index < TICKS; index++) {
    hud.box({ id: `tick-${index}`, parent: "bar", visible: false,
      layout: { x: 0.5, y: 0.1, anchor: "top", width: 0.0015, height: 0.03 } });
  }
}

export default defineMod({
  id: "cookbook.mini-hit-bar",
  name: "Mini hit bar",
  version: "1.0.0",
  apiVersion: 2,
  setup() {
    ctx.on("game.songStart", (start) => build(start.judgements));
    ctx.on("game.tick", (tick) => {
      if (tick.hitCount === lastCount) return;
      lastCount = tick.hitCount;
      const hits = game.hits({ limit: TICKS });
      for (let index = 0; index < TICKS; index++) {
        const hit = hits[index];
        if (!hit) { hud.update({ id: `tick-${index}`, visible: false }); continue; }
        hud.update({ id: `tick-${index}`, visible: true, layout: { x: place(hit.offsetMs) },
          style: { background: tiers[hit.tier].color } });
      }
    });
    ctx.on("game.songEnd", () => hud.clear());
  },
});

5. A skin-configurable element (provides.elements)

Provide a widget that skin.ts (recipe 11) places with ctx.element(...).configure. It receives the options on every configuration (elements.configure). A pattern common to all the bundled HUD mods (mods/combo, mods/accuracy…).

/// <reference path="../sdk/modding.d.ts" />

const OPTIONS = {
  x: { type: "number", min: 0, max: 1, default: 0.5, label: "Horizontal position" },
  y: { type: "number", min: 0, max: 1, default: 0.9, label: "Vertical position" },
  anchor: { type: "enum", values: ["topLeft", "top", "topRight", "left", "center", "right", "bottomLeft", "bottom", "bottomRight"], default: "bottom", label: "Anchor" },
  size: { type: "number", min: 0.005, max: 0.3, default: 0.03, label: "Size" },
  visible: { type: "boolean", default: true, label: "Show" },
  textColor: { type: "color", default: "#edf0f6", label: "Color" },
} satisfies Record<string, ElementOption>;

type Options = { x: number; y: number; anchor: Anchor; size: number; visible: boolean; textColor: string };
const declared: Record<string, ElementOption> = OPTIONS;
// Before any configuration, the element has its declared default values.
let options = Object.fromEntries(Object.entries(declared).map(([name, option]) => [name, option.default])) as Options;
let playing = false;

function build() {
  hud.remove("title");
  if (!playing || !options.visible) return;
  hud.text({ id: "title", text: "", bind: { kind: "total" }, element: "timer",
    layout: { x: options.x, y: options.y, anchor: options.anchor },
    style: { size: options.size, color: options.textColor } });
}

export default defineMod({
  id: "cookbook.duration-badge",
  name: "Duration badge",
  version: "1.0.0",
  apiVersion: 2,
  provides: { elements: { badge: { root: "title", options: OPTIONS } } },
  setup() {
    ctx.on("elements.configure", (configure) => {
      if (configure.element !== "badge") return;
      options = configure.options as Options;
      build();
    });
    ctx.on("game.songStart", () => { playing = true; build(); });
    ctx.on("game.songEnd", () => { playing = false; hud.remove("title"); });
  },
});

6. A gameplay modifier (gameplay.register)

A script does not create behavior: it declares a kind that the host implements (auto, ghost, mirror, random, noLn, fullLn). Identical to mods/mirror/main.ts.

/// <reference path="../sdk/modding.d.ts" />

export default defineMod({
  id: "cookbook.my-mirror",
  name: "My mirror",
  version: "1.0.0",
  apiVersion: 2,
  setup() {
    gameplay.register({
      id: "mirror",
      name: "Mirror",
      description: "Flips the columns left to right. Scored and recorded like any other play.",
      kind: "mirror",
      group: "layout",
      icon: "flip-horizontal-2",
    });
  },
});

7. A key-bound action (controls.register)

The action appears in Settings → Controls under the mod's name. defaultKey is a physical code (KeyH…). Events can be dropped under load: do not derive any critical state from them.

/// <reference path="../sdk/modding.d.ts" />

export default defineMod({
  id: "cookbook.toggle-hud",
  name: "Toggle HUD",
  version: "1.0.0",
  apiVersion: 2,
  setup() {
    controls.register({ id: "toggle", name: "Show or hide the combo", defaultKey: "KeyH" });
    let visible = true;
    ctx.on("game.songStart", () => {
      visible = true;
      hud.text({ id: "combo", text: "", bind: { kind: "combo" }, layout: { x: 0.5, y: 0.3, anchor: "center" }, style: { size: 0.08 } });
    });
    ctx.on("controls.action", (action) => {
      if (action.id !== "toggle" || !action.pressed) return;
      visible = !visible;
      hud.update({ id: "combo", visible });
    });
    ctx.on("game.songEnd", () => hud.clear());
  },
});

8. A table, a difficulty view with panels, a library filter

A mod declares its table (tables.register), a view that reads its column (ratings.register, with panels) and a search filter (library.filters.register) on the same column. The values come from a native calculator (metron.catalog()), which ratings.backfill triggers. Based on Difficulty ratings and mod tables and mods/metron/main.ts.

/// <reference path="../sdk/modding.d.ts" />

export default defineMod({
  id: "cookbook.quaver-stars",
  name: "Quaver stars",
  version: "1.0.0",
  apiVersion: 2,
  setup() {
    const calculator = metron.catalog().calculators.find((entry) => entry.id === "quaver-2025");
    if (!calculator) return; // calculator missing: the mod declares nothing
    tables.register({ id: "quaver_rating", columns: [{ name: "value", kind: "number", indexed: true }] });
    ratings.register({
      id: "stars", name: "Quaver stars", calculator: calculator.id, unit: "★",
      table: "quaver_rating", column: "value", version: calculator.version,
      panels: [
        { kind: "metrics", title: "Chart profile", fields: [
          { label: "Stars", source: { kind: "column", column: "value" }, unit: "★", decimals: 2 },
          { label: "Long notes", source: { kind: "chart", metric: "holdPercent" }, unit: "%", decimals: 1 },
        ] },
        { kind: "timeline", title: "Timeline", series: [{ label: "Density", source: "density", unit: "NPS" }] },
      ],
    });
    library.filters.register({
      id: "stars", name: "Quaver stars", table: "quaver_rating", column: "value",
      version: calculator.version, unit: "★",
    });
    // Computes missing or stale charts: at startup, then on every import.
    ctx.on("library.ready", () => { ratings.backfill({ id: "stars" }); });
    ctx.on("library.chartsAdded", (added) => { ratings.backfill({ id: "stars", charts: added.chartIds }); });
    ctx.on("ratings.progress", (progress) => {
      if (progress.id === "stars" && progress.error) log.warn(progress.error);
    });
  },
});

Pitfalls: the table and column must belong to this mod; the column must be number for a view; version must equal calculator.version; an unavailable value is displayed as "—" (never an invented zero).

9. A native effect with stage.* (permission)

Sparks on impact, frame-accurate. Requires permissions: ["stage"]. Everything is declared once: the renderer plays animations and triggers. Based on examples/mods/hit-bursts.

/// <reference path="../sdk/modding.d.ts" />

export default defineMod({
  id: "cookbook.sparks",
  name: "Sparks",
  version: "1.0.0",
  apiVersion: 2,
  permissions: ["stage"],
  setup() {
    ctx.on("game.songStart", (start: SongStartEvent) => {
      stage.clear();
      const perfect = start.judgements.tiers[0];
      stage.emitter({ id: "sparks", at: { space: "receptor" }, size: 0.008, color: perfect.color,
        lifetimeMs: 380, speed: 0.55, spread: 150, gravity: 1.6, burst: 14, maxParticles: 120 });
      stage.trigger({ id: "hit", on: { judgement: { tier: 0 } }, target: "sparks", play: "burst" });
    });
  },
});

10. A mod that keeps a record (storage.*)

A JSON file specific to the mod, kept between plays (and when uninstalling).

/// <reference path="../sdk/modding.d.ts" />

export default defineMod({
  id: "cookbook.best-combo",
  name: "Best combo",
  version: "1.0.0",
  apiVersion: 2,
  setup() {
    ctx.on("game.songEnd", (end) => {
      if (end.aborted) return;
      const best = Number(storage.get("bestCombo") ?? 0);
      if (end.maxCombo > best) {
        storage.set({ key: "bestCombo", value: end.maxCombo });
        log.info(`new best combo: ${end.maxCombo}`);
      }
    });
  },
});

11. A skin that configures elements (defineSkin + ctx.element)

A skin places the native playfield and configures the elements that mods provide. In skins/<folder>/skin.ts. The element ids and their options are those of the bundled mods (mods/combo, mods/accuracy, mods/judgement-display); ctx.has avoids failing if the mod is absent. Based on skins/default/skin.ts.

/// <reference path="../../mods/sdk/modding.d.ts" />

export default defineSkin({
  id: "cookbook.neon",
  name: "Cookbook Neon",
  version: "1.0.0",
  apiVersion: 2,
  uses: {
    "pvng.combo": { version: "^1", feature: "Combo" },
    "pvng.accuracy": { version: "^1", feature: "Accuracy" },
  },
  // `ctx` is the global of `ctx.on`: the launch context is called `play`.
  setup(play: PlayContext) {
    playfield.set({ x: 0.5, y: 0.5, anchor: "center", laneWidth: 0.07, laneGap: 0.004, noteColor: "#ff4080", scroll: "down" });
    if (play.columns >= 7) playfield.set({ laneWidth: 0.06 });
    if (ctx.has("pvng.combo")) {
      ctx.element("pvng.combo/combo").configure({ x: 0.5, y: 0.3, anchor: "center", size: 0.08, textColor: "#ffffff" });
    }
    if (ctx.has("pvng.accuracy")) {
      ctx.element("pvng.accuracy/accuracy").configure({ x: 0.98, y: 0.02, anchor: "topRight", size: 0.035, decimals: 2 });
    }
  },
});

12. A map script (defineChart)

Place it next to the chart: script.ts (all difficulties) or <chart file>.script.ts. Visual only; no HUD or storage. Each change is a transition animated by the renderer: the script only runs on each beat. Reduced version of examples/map-scripts/first-light/script.ts.

/// <reference path="../../../mods/sdk/modding.d.ts" />

const LANE = "#454d61";
const DOWNBEAT = "#a67cff";
const BEAT = "#7486b8";

export default defineChart({
  apiVersion: 2,
  setup(play: PlayContext) {
    playfield.set({ laneColor: LANE });
    let side = 1;
    ctx.on("game.beat", (beat: Beat) => {
      const beatMs = 60000 / beat.bpm;
      if (beat.meterBeat === 0) {
        side = -side;
        playfield.update({ patch: { x: 0.5 + side * 0.05, rotation: side * 3 }, transitionMs: Math.min(10000, 4 * beatMs), easing: "easeInOut" });
      }
      // A flash right away, then a return before the next beat.
      playfield.update({ patch: { laneColor: beat.meterBeat === 0 ? DOWNBEAT : BEAT } });
      playfield.update({ patch: { laneColor: LANE }, transitionMs: 0.9 * beatMs, easing: "easeIn" });
    });
    log.info(`map script for ${play.song.title} (${play.columns}K)`);
  },
});

13. Reading the local leaderboard (leaderboard.best)

Asynchronous read: leaderboard.best returns a request number (or null when 2 requests from the mod are already pending) and the response arrives via leaderboard.result, to the requesting mod only. The chartId is the library's (library.chartAdd, library.chartsAdded): game.song() does not carry one (see LACUNES.md), so a "record for the current chart" HUD is not feasible with these functions alone; for a display tied to the selected chart, use a declarative tab (recipe 14). Here: a small menu popup with the best score of the first chart of an import.

/// <reference path="../sdk/modding.d.ts" />

export default defineMod({
  id: "cookbook.best-reader",
  name: "Best reader",
  version: "1.0.0",
  apiVersion: 2,
  setup() {
    ctx.on("library.chartsAdded", (added) => {
      const chartId = added.chartIds[0];
      if (chartId === undefined) return;
      // null: too many pending requests, give up (no automatic retry).
      if (leaderboard.best({ chartId }) === null) log.warn("leaderboard busy");
    });
    ctx.on("leaderboard.result", (result) => {
      if (result.error) { log.warn(result.error); return; }
      const best = result.entries[0];
      if (!best) return; // no local score on this chart
      const performance = best.performance == null ? "—" : `${best.performance.toFixed(2)} ${result.performance?.unit ?? ""}`;
      ui.popup({
        title: "Best local play",
        // playerName is null for an old replay without a name: do not attribute it to the current player.
        detail: `#${best.rank} ${best.playerName ?? "unknown"} · ${best.accuracy.toFixed(2)}% · ${performance} (${result.judgement})`,
        done: 1, total: 1,
        actions: [],
      });
    });
    ctx.on("ui.action", (action) => { if (action.id === "dismiss") ui.dismiss(); });
  },
});

14. A selection tab with panels (tabs.register, tabs.extend)

Declarative: typed panels whose values the host reads (a column from a difficulty view of the same mod, a chart statistic, a leaderboard). A tab appears after Info, Leaderboard, Mods… ; tabs.extend adds sections to the existing tabs. Based on Tabs in the selection screen: tabs.*.

/// <reference path="../sdk/modding.d.ts" />

export default defineMod({
  id: "cookbook.skills-tab",
  name: "Skills tab",
  version: "1.0.0",
  apiVersion: 2,
  setup() {
    const calculator = metron.catalog().calculators.find((entry) => entry.id === "etterna-515");
    if (!calculator) return;
    // The column read by a `column` field must come from a `ratings.register` view of THIS mod.
    tables.register({ id: "skills_rating", columns: [{ name: "stream", kind: "number", indexed: true }] });
    ratings.register({
      id: "skills", name: "Skills", calculator: calculator.id, unit: "MSD",
      table: "skills_rating", column: "stream", version: calculator.version,
    });
    tabs.register({
      id: "skills", title: "Skills", icon: "gauge", order: 10,
      panels: [
        { kind: "metrics", title: "Overview", fields: [
          { label: "Stream", source: { kind: "column", rating: "skills", column: "stream" }, unit: "MSD", decimals: 1 },
          { label: "Plays", source: { kind: "leaderboard", stat: "plays" }, decimals: 0 },
          { label: "Best accuracy", source: { kind: "leaderboard", stat: "bestAccuracy" }, unit: "%", decimals: 2 },
        ] },
        { kind: "leaderboard", title: "Best plays", limit: 5 },
        { kind: "text", title: "Note", text: "Values of the base chart." },
      ],
    });
    // A strip below the content of the host's Info tab.
    tabs.extend({ tab: "info", slot: "bottom", panels: [{ kind: "text", text: "Added by Skills tab." }] });
    ctx.on("library.ready", () => { ratings.backfill({ id: "skills" }); });
  },
});

15. A declarative download source (downloads.register)

network permission. The script only declares data (URL templates, JSON paths, allowed hosts, rate); the game does all the HTTP. The example.org hosts are fictional: replace them with a service you are allowed to use. A reduced version of examples/mods/download-sources (branch feature/mod-download-sources).

/// <reference path="../sdk/modding.d.ts" />

export default defineMod({
  id: "cookbook.maps-source",
  name: "Maps source",
  version: "1.0.0",
  apiVersion: 2,
  permissions: ["network"],
  setup() {
    // One more mirror for osu! beatmapsets (responses in osu! API v2 format).
    downloads.register({
      id: "mirror", name: "Example mirror", kind: "mirror", target: "osu",
      hosts: ["mirror.example.org"],
      search: {
        url: "https://mirror.example.org/api/search",
        params: { query: "{query}[ cs>={keysMin}]", amount: "{limit}", offset: "{offset}" },
        paging: { kind: "offset", size: 20 },
        response: { format: "osu" },
      },
      download: { url: "https://mirror.example.org/d/{id}" },
    });
    // One more tab in Download, responses read field by field.
    downloads.register({
      id: "maps", name: "Example maps", kind: "source",
      hosts: ["api.example.org", "files.example.org"],
      rateLimit: 60,
      auth: { kind: "token", scope: "download" }, // token entered by the player, never given to the script
      search: {
        url: "https://api.example.org/v1/search",
        params: { q: "{query}", order: "{sort}", offset: "{offset}", limit: "{limit}" },
        sorts: { newest: "date", plays: "plays" },
        paging: { kind: "offset", size: 20 },
        response: {
          format: "mapped", results: "data.items", total: "data.total",
          item: { id: "uuid", title: "title", artist: "artist", creator: "mapper",
            difficulties: { path: "charts", name: "name", keys: "keys", stars: "stars", only: { path: "mode", equals: "mania" } } },
        },
      },
      download: { urlField: "files.zip" },
    });
  },
});

Pitfalls: exact lowercase hosts (no scheme, port, wildcard, or IP address); {id} only in download.url; URLs read from a response must stay on a declared host; JSON keys containing a dot cannot be described.

16. A mod-written API bridge (downloads.registerBridge)

When the API cannot be described as data (here: a POST search, an XML response, then a detail request to find the file). Three pure functions: they return the next step (BridgeStep), the host makes the request and calls onResponse back. XML and HTML arrive as text (no DOM parser). A trimmed-down version of examples/mods/download-bridge.

/// <reference path="../sdk/modding.d.ts" />

interface State { page?: number; detailOf?: string }

export default defineMod({
  id: "cookbook.maps-bridge",
  name: "Maps bridge",
  version: "1.0.0",
  apiVersion: 2,
  permissions: ["network"],
  setup() {
    downloads.registerBridge({
      id: "maps", name: "Example maps",
      hosts: ["maps.example.org", "files.example.org"],
      auth: { kind: "token", header: "Authorization", scope: "download" },
      search(query, page): BridgeStep {
        return {
          request: { url: "https://maps.example.org/cgi/find", method: "POST", form: { terms: query, page: String(page) }, expect: "xml" },
          state: { page: Number(page) },
        };
      },
      onResponse(response, state): BridgeStep {
        const carried = (state ?? {}) as State;
        if (response.status !== 200) return { error: `the site answered ${response.status}` };
        if (carried.detailOf !== undefined) {
          const detail = JSON.parse(response.text) as { file?: string };
          if (!detail.file) return { error: "this map has no file" };
          return { download: { url: `https://files.example.org${detail.file}`, format: "osz" } };
        }
        const rows = [...response.text.matchAll(/<map id="(\d+)"><title>([^<]*)<\/title><artist>([^<]*)<\/artist><\/map>/g)];
        return {
          results: rows.map((row) => ({ id: row[1], title: row[2], artist: row[3], actions: [{ id: "download", label: "Download" }] })),
          nextPage: rows.length >= 2 ? (carried.page ?? 1) + 1 : undefined,
          state: carried,
        };
      },
      action(result): BridgeStep {
        return { request: { url: `https://maps.example.org/maps/${result.id}.json`, expect: "json" }, state: { detailOf: result.id } };
      },
    });
  },
});

Pitfalls: an exception, a result that JSON cannot serialize, or exceeding the budget disables the bridge; an invalid step (unknown field, undeclared host, forbidden header) is an error that is displayed without disabling the bridge; at most 6 requests per search or action; the token is never visible (replaced by [token] in responses).

17. Converting an osu! skin (skinImport.*): usage note

skinImport permission, mods only. It is an event flow: the player picks a source from your importer's card (Skins page) → skinImport.opened (the parsed skin.ini and the image inventory, with no bytes at all) → you compute a SkinDescription and ImageOps → skinImport.stage → skinImport.staged (report, 900 KiB budget reported but not enforced) → skinImport.create → skinImport.created (id of the skin written). The mod writes nothing itself; the status card is described with skinImport.panel. A real converter is long: the complete example is mods/skin-converter (main.ts, convert.ts, strings.ts); below is the skeleton of the flow (the description produced is deliberately empty: it shows the calls, not a useful skin).

/// <reference path="../sdk/modding.d.ts" />

let sourceId = 0;

export default defineMod({
  id: "cookbook.skin-import",
  name: "Skin import skeleton",
  version: "1.0.0",
  apiVersion: 2,
  permissions: ["skinImport"],
  setup() {
    skinImport.register({ id: "skeleton", name: "Skeleton importer", sources: ["folder", "archive"] });
    ctx.on("skinImport.opened", (opened) => {
      if (opened.error || !opened.source) {
        skinImport.panel({ title: "Skeleton importer", status: "error", detail: opened.error ?? "empty source" });
        return;
      }
      sourceId = opened.source.sourceId;
      skinImport.panel({
        title: opened.source.ini?.general.name ?? opened.source.label,
        status: "ready",
        sections: [{ heading: "Source", rows: [{ label: "Images", value: String(opened.source.images.length) }] }],
        actions: [{ id: "convert", label: "Convert", primary: true }],
      });
    });
    ctx.on("skinImport.action", (action) => {
      if (action.id === "dismiss") { skinImport.close(); return; }
      if (action.id !== "convert") return;
      skinImport.panel({ title: "Skeleton importer", status: "working" });
      skinImport.stage({ sourceId, skin: { id: "skeleton-skin", name: "Skeleton skin", layouts: [{ keys: 4, lanes: [] }] }, images: [] });
    });
    ctx.on("skinImport.staged", (staged) => {
      if (staged.error || staged.stageId == null) {
        skinImport.panel({ title: "Skeleton importer", status: "error", detail: staged.error ?? "nothing staged" });
        return;
      }
      skinImport.create({ stageId: staged.stageId });
    });
    ctx.on("skinImport.created", (created) => {
      skinImport.panel({
        title: "Skeleton importer",
        status: created.error ? "error" : "done",
        detail: created.error ?? `Created ${created.id}`,
        actions: created.id ? [{ id: "edit", label: "Edit skin", editSkin: created.id }] : [],
      });
    });
  },
});

What the recipes do not cover

Nothing here invents an API. For a need that is not in Mod API reference (version 2) (playing a sound, reading or writing settings, a HUD showing the record for the current chart, filling your own table…), see LACUNES.md: say that the API does not allow it rather than guessing.

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