@idosgames/mcp 0.1.35 → 0.1.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/cli.js +1 -1
  2. package/package.json +1 -1
  3. package/registry/host.json +1 -1
  4. package/registry/index.json +124 -120
  5. package/registry/modules/ai.json +3 -3
  6. package/registry/modules/board-game.json +37 -37
  7. package/registry/modules/boosts.json +3 -3
  8. package/registry/modules/character.json +15 -15
  9. package/registry/modules/chat.json +3 -3
  10. package/registry/modules/collections.json +3 -3
  11. package/registry/modules/coop-events.json +3 -3
  12. package/registry/modules/craft.json +3 -3
  13. package/registry/modules/deals.json +3 -3
  14. package/registry/modules/events.json +8 -8
  15. package/registry/modules/friends.json +3 -3
  16. package/registry/modules/idle-rpg.json +3 -3
  17. package/registry/modules/inventory.json +3 -3
  18. package/registry/modules/invite.json +3 -3
  19. package/registry/modules/leaderboard.json +12 -12
  20. package/registry/modules/lootboxes.json +8 -8
  21. package/registry/modules/mailbox.json +3 -3
  22. package/registry/modules/marketplace.json +3 -3
  23. package/registry/modules/mini-game.json +7 -7
  24. package/registry/modules/multiplayer.json +28 -16
  25. package/registry/modules/premium.json +3 -3
  26. package/registry/modules/profile.json +3 -3
  27. package/registry/modules/pvp.json +3 -3
  28. package/registry/modules/quests.json +10 -10
  29. package/registry/modules/rewards.json +3 -3
  30. package/registry/modules/season.json +3 -3
  31. package/registry/modules/store.json +3 -3
  32. package/registry/modules/tutorials.json +3 -3
  33. package/registry/modules/voxelcraft.json +13 -13
  34. package/registry/modules/workshop.json +3 -3
  35. package/registry/skills/idosgames-module-contract.json +1 -1
  36. package/registry/skills/leaderboard-system.json +2 -2
  37. package/registry/skills/multiplayer-realtime.json +1 -1
@@ -39,9 +39,9 @@
39
39
  "version": "0.1.3"
40
40
  },
41
41
  "dependencies": {
42
- "@idosgames/core": "0.21.6",
43
- "@idosgames/module-sdk": "0.11.0",
44
- "@idosgames/react": "0.5.1",
42
+ "@idosgames/core": "0.22.0",
43
+ "@idosgames/module-sdk": "0.12.0",
44
+ "@idosgames/react": "0.5.2",
45
45
  "react": "19.2.7"
46
46
  },
47
47
  "contentHash": "ba3020258426f2c8b890d16cd169bffb4e96a4fc61d8a312c24d706ec9eac315",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idosgames-module-contract",
3
3
  "description": "Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk: the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route registration, and the shared controller-box bridge between an imperative scene and React panels. Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg, voxelcraft, mini-game, store, character, workshop…), or asks about defineModule, registerScene/registerPanel/registerRoute, ctx.effect / ctx.every (background work: badges, polling), ctx.features (register / open / setBadge / setAvailable), ctx.modes, activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic, ctx.content / ContentTypeHandler (letting the Workshop publish and open the game's content), ctx.multiplayer / registerGame (playing online through the lobby's rooms system), ctx.lobby / setHero (a game's showcase on the lobby's home — its character on a pedestal + \"Play\"), ctx.navigate, the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention.",
4
- "content": "---\nname: idosgames-module-contract\ndescription: >-\n Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk:\n the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route\n registration, and the shared controller-box bridge between an imperative scene and React panels.\n Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg,\n voxelcraft, mini-game, store, character, workshop…), or asks about defineModule,\n registerScene/registerPanel/registerRoute, ctx.effect / ctx.every (background work: badges, polling),\n ctx.features (register / open / setBadge / setAvailable),\n ctx.modes,\n activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic,\n ctx.content / ContentTypeHandler (letting the Workshop publish and open the game's content),\n ctx.multiplayer / registerGame (playing online through the lobby's rooms system),\n ctx.lobby / setHero (a game's showcase on the lobby's home — its character on a pedestal + \"Play\"),\n ctx.navigate, the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention.\n---\n\n# The module contract (@idosgames/module-sdk)\n\nA module is a manifest the host installs once. It exports `{camelCase(id)}Module` from its\n`index.ts` (e.g. `board-game` → `boardGameModule`, `idle-rpg` → `idleRpgModule`) — the host seeder\nderives the import name from the id, so this convention is required.\n\n```ts\nimport { defineModule } from \"@idosgames/module-sdk\";\n\nexport const boardGameModule = defineModule({\n id: \"board-game\",\n meta: { name: \"Board Game\", type: \"game\", genre: \"board\", engine: \"three\" }, // engine: three|phaser|dom\n setup(ctx) {\n // ctx.client (shared, authed) · ctx.events (typed cross-module bus) · ctx.sharedUi · ctx.surface\n ctx.registerScene(createScene(box)); // rendered engine scene (optional)\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: RootPanel }); // React UI (optional)\n ctx.registerRoute({ id: \"board-game\", label: \"Board\", icon: \"🎲\" }); // a mode → the lobby's \"Play\"\n },\n});\n```\n\nTwo kinds of module plug into the base (sign-in and the lobby, from `@idosgames/app-shell`):\n\n- a **game** (`meta.type: \"game\"`) registers a route: the lobby's \"Play\" launches it on the whole\n screen with a small \"To the lobby\" button over it (none with `ownExit: true` — the game leads back\n by itself, voxelcraft's pause menu); with `inLobby: true` on the route (idle-rpg) it\n instead lives INSIDE the lobby — the base keeps its balances and tabs around the game through the\n host's `header`/`footer` panel slots (the canvas and every other slot are laid out between them,\n so a game needs nothing for it) and the app starts in it;\n- a **system** (`meta.type: \"app\"`) registers a feature — its screen becomes a lobby tab:\n `ctx.features.register({ id, label, icon, group, order, primary?, available?, Screen })`. A tab\n that depends on title data registers with `available: false` and calls\n `ctx.features.setAvailable(id, ok, reason)` once it knows (details: **idosgames-ui-kit**).\n Give `label` as a function — `label: () => t(\"title\")` — so the tab follows the player's language\n switch without a reload (a plain string stays as registered).\n\n`ctx.modes.list()` / `current()` / `subscribe()` — every mode (id, label, icon, module, module type),\nfilled once all modules are installed (read it at render time, not inside `setup()`).\n`ctx.navigate(id)` switches (`\"lobby\"` is the base); called inside `setup()` it picks the mode the app\nstarts in. `ctx.features.open(id, args)` from a game (the lobby not on screen) switches to the lobby\nand opens that tab there.\n\n`ctx.lobby` — the base's lobby as seen by a game. A game NEVER draws a lobby of its own (no own\n\"Play\" screen, no own menu of creation screens): sign-in, balances, profile and every system are the\nbase's, and the game plugs into it —\n\n- `ctx.lobby.setHero({ mode, mount(host, stage), view(), subscribe(fn), beforePlay? })` puts its\n showcase on the lobby's home (\"Play\" tab), Fortnite-style: `mount` draws into an element filling\n the home behind the lobby's interface (any engine; stand the figure in `stage.spot()` — CSS px of\n the host, read it every frame), returns the cleanup; `view()` describes what the lobby lays out\n with the UI kit around it — `title` / `subtitle` under the figure, `actions` there (\"Edit\",\n \"Emote\" with a `menu`), and the `play` card next to the big \"Play\" (`caption`, `title`, `actions`,\n `more` for its \"⋯\"). The lobby's \"Play\" switches to `mode`. Reference: voxelcraft `src/lobby/hero.ts`;\n- the game's own sections (worlds, character editor, items…) are ordinary features\n (`ctx.features.register`, screens on the UI kit) — tabs next to the shop and the quests;\n- `ctx.lobby.available()` / `open()` — \"To the lobby\" in the game's pause menu (hide it when false).\n Works before the game's scene is mounted: keep what the tabs and the hero need outside the scene\n (voxelcraft: `src/hub.ts`) and hand the choice made there to the scene when it starts.\n\n`ctx.multiplayer` — a game that plays online registers itself (`registerGame({ mode, topology,\nchatMode, maxPlayers, joinInProgress, settings, start(room) })`) and takes the room the lobby's\nrooms system hands it on \"Play\"; it never creates or joins rooms (skill **multiplayer-realtime**).\n\nOptional static field: `sharedUi: { provides?: SharedUiRole[]; requires?: SharedUiRole[] }` — see\n\"Shared chrome\" below.\n\n`meta.type` is `game | app | ai-app`; `meta.engine` is `three | phaser | dom` (`dom` = no renderer —\na pure React/DOM app module). `setup` is called ONCE with `ctx: ModuleContext`.\n\n## EngineScene (the rendered part)\n\nA scene mounts into a bare `HTMLElement` (framework-free — this is why a vanilla Three game like\nvoxelcraft fits). The host's Mode Router drives it; only the active mode runs.\n\n```ts\nconst scene: EngineScene = {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext) {\n controller = new Controller(ctx.host);\n box.set(controller);\n },\n activate() {\n controller?.setRunning(true);\n }, // became the active mode → resume RAF\n suspend() {\n controller?.setRunning(false);\n }, // hidden → stop RAF (invariant: only active ticks)\n destroy() {\n controller?.destroy();\n }, // permanent teardown\n};\n```\n\n`mount` takes a context object (not a bare element) so it can grow — e.g. a host-shared renderer in\nthe composition era — without breaking the contract. A scene MUST stop its RAF on `suspend`.\n\n### Loading — never a still screen\n\nA scene's FIRST start goes behind the app's loading screen by itself: the host shows it (themed,\n\"Loading «<mode>»…\"), lets it paint, THEN calls `mount` — so heavy synchronous work in `mount`\n(generating terrain, building meshes) never looks like a frozen app. The screen goes away after the\nfirst frame, or — when the scene has more to do — when its optional `ready(): Promise<void>` resolves\n(voxelcraft: the last played world restored from storage). Never draw your own start-up spinner.\n\nLong work AFTER the start goes through `ctx.loading` — the same screen:\n\n- `await ctx.loading.track(fetchWorld(), \"Loading the world…\")` — async work;\n- `await ctx.loading.run(() => buildWorld(seed), \"Building the world…\")` — work that BLOCKS the page:\n the screen is shown and painted first (calling heavy code right after `begin()` freezes the page\n before the screen appears);\n- `const task = ctx.loading.begin(\"…\"); task.progress(0.4, \"Textures…\"); task.done();` — with progress.\n\n### Notifications — `ctx.notifications`\n\nThe app's one system of toasts and alert windows (the UI kit draws them, themed): never draw a banner\nof your own. `ctx.notifications.toast({ level, title, message, icon, image, source, key, actions })`\n— `level`: `info | success | warning | error | reward`; `source` defaults to the module's id (the\ntoast shows that screen's name and a tap opens it). `await ctx.notifications.alert({ level, title,\nmessage, actions: [{ id: \"go\", label: \"Open\", primary: true, openFeature: \"season\" }, { label: \"Later\" }] })`\nresolves with the pressed action's id (`null` — closed). React screens: `useNotify()` from the kit.\n\nA module that registers a scene MUST also publish its debug surface — `ctx.exposeToAgent({ state,\nactions, describeActions })` — and must NOT gate controls on Pointer Lock (unavailable in the\npreview's cross-origin iframe). Nothing inside a `<canvas>` is observable from the DOM, so without\nthe surface neither the AI Coder nor a human reviewer can tell what the game is doing. See the\n`idosgames-agent-debug-surface` skill.\n\n### `capture()` — the frame for \"Take a shot and share\"\n\nidosgames.com has a **Shot** button under the game: it asks the game for one frame, puts the\nplatform's branding and a QR code on it and offers to post it. A scene answers through the optional\n`capture?(): Promise<Blob | null>`. The host (`@idosgames/app-shell`) wires the request itself —\na module only has to return a picture of its active scene.\n\n**Implement it in any scene that renders to a canvas.** Without it the host falls back to reading\nthe `<canvas>` from outside, and for WebGL that is a trap: a renderer created WITHOUT\n`preserveDrawingBuffer` (the default, and the right default — the flag costs memory and a copy every\nframe) has already cleared its buffer by the time anyone reads it, so `toBlob()` returns a **fully\nblack** image and reports nothing. The host rejects single-colour frames, so the player gets the\ngame's cover instead of their moment.\n\nRender and read in the SAME tick:\n\n```ts\n// three.js\ncapture() {\n return new Promise((resolve) => {\n renderer.render(scene, camera); // draw now…\n renderer.domElement.toBlob(resolve, \"image/png\"); // …and read before the buffer is cleared\n });\n},\n\n// Phaser — use its own snapshot, it handles the timing\ncapture() {\n return new Promise((resolve) => {\n setTimeout(() => resolve(null), 1500); // no frame came (context lost) → answer \"no\", don't hang\n game.loop.wake(); // a sleeping loop never draws again → snapshot never fires\n game.renderer.snapshot((image) => {\n if (!(image instanceof HTMLImageElement)) return resolve(null);\n const c = document.createElement(\"canvas\");\n c.width = image.naturalWidth || image.width;\n c.height = image.naturalHeight || image.height;\n c.getContext(\"2d\")?.drawImage(image, 0, 0);\n c.toBlob(resolve, \"image/png\");\n });\n });\n},\n```\n\nAlways answer — with a Blob or `null`. A `capture()` that never settles makes the site wait out its\ntimeout before it falls back, and the player stares at a spinner.\n\nReturn `null` when there is nothing worth showing (loading, no scene yet) — the site then uses the\ncover. Only the scene is captured, not the React panels over it: a shot for a feed reads better\nwithout the HUD. DOM-only modules (no canvas) simply omit `capture`. Working references:\n`modules/board-game` and `modules/voxelcraft` (three), `modules/idle-rpg` (Phaser).\n\n## UiPanel (the React part)\n\nPanels are React components the host renders inside its provider stack (client + status already\nprovided). Use `@idosgames/react` hooks (`useIDosGamesClient`, `useUserState`) — do NOT re-create\nproviders.\n\n- `slot`: `hud | sidebar | overlay | modal`.\n- `activeOnly` (default true): show only while this module's mode is active. Set `false` for shared\n chrome that stays across every mode (e.g. a persistent HUD).\n- `overlay`/`sidebar` layers sit between the shared HUD and the nav (the host measures both), so a\n panel positioned `absolute` inside its layer never ends up under them. Things drawn outside the\n layers use `var(--idos-safe-top, 0px)` / `var(--idos-safe-bottom, 0px)`.\n- A `hud` panel's wrapper is `display: contents`: the panel is a direct flex child of the HUD row\n (can `flex: 1`) and must set `pointer-events: auto` only on its controls.\n\n## Shared chrome (`sharedUi`)\n\nRoles: `currency-bar` (virtual-currency balances), `wallet` (crypto wallet entry — `LazyWalletPanel`),\n`status` (the `useStatus()` line), `account` (Log out + player ID), `menu` (the menu of features),\n`modes` (the switch between modes). The base takes `currency-bar`, `wallet`, `account`, `menu` and\n`modes` — its lobby draws them; `status` stays with each game. A module that draws one for every\nmode declares `sharedUi: { provides: [...] }` on the module object — statically, so the host resolves\nowners before any `setup()` and the answer never depends on module order.\n\n**Rule for templates: yield every role you draw yourself.** Read it once in `setup()` and close over\nthe answer (it is fixed for the session):\n\n```ts\nsetup(ctx) {\n const chrome = {\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n balances: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n };\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: makeRootPanel(box, chrome) });\n}\n```\n\nHide only the SHARED pieces — genre UI (the board's stage/tile/cycle chips) always stays. Never\nwrap your UI in your own `<StatusProvider>`: the host provides ONE, and a nested one would swallow\nyour messages so the shared status line never sees them. A module that needs a role but does not\ndraw it declares `sharedUi: { requires: [\"currency-bar\"] }`. `ctx.sharedUi.ownerOf(role)` names the\nprovider (or `null`). A full-screen mode (its own bottom tab bar) that shows the player ID and\n\"Log out\" in its own header declares `sharedUi: { drawsOwn: [\"account\"] }` and draws them only while\n`shouldDraw(\"account\")`: the host then hides its floating bottom-left copy while that mode is on\nscreen (it would cover the tab bar) and keeps it in the other modes. `drawsOwn` takes no role —\nwith the base installed the lobby draws the account and the mode hides its copy.\n\n## Events (`ctx.events`)\n\nTyped topic tokens — `defineTopic(\"<your-id>:<event>@1\", shape({ … }))` from\n`@idosgames/module-sdk`; emit only your own namespace; to listen to another module, copy its topic\nand payload descriptor from the catalog into your own `defineTopic` (never import it); declare both\nin `module.meta.json` (`events.emits` / `events.listens`). **Subscribe in `setup()`**, not in a panel\neffect — `activeOnly` panels unmount with their mode and miss events; store what arrives in a small\nstore the panel reads. Full rules and examples: **idosgames-compose-modules** (\"Signals between\nmodules\"). The string overloads (`emit(\"x\", …)`) are deprecated.\n\n## Content types (`ctx.content`) and switching modes (`ctx.navigate`)\n\nA game whose players make things (worlds, levels, skins) registers a **content type handler** so the\nshared Workshop module can publish and open them — the Workshop never imports the game:\n\n```ts\nsetup(ctx) {\n ctx.content.registerType({\n type: \"voxelcraft.world\", // = a key of Workshop.ContentTypes in the title config\n label: \"VoxelCraft world\",\n icon: \"⛏️\",\n modeId: \"voxelcraft\", // route to switch to after open()\n async listLocal() { return saves.map((s) => ({ id: s.id, name: s.name, updatedAt: s.at })); },\n async capture(localId) { // roles/MIME types must fit the title's content type config\n return { files: [{ role: \"main\", contentType: \"application/json\", data: json }],\n thumbnail: { contentType: \"image/webp\", data: webp }, suggestedTitle: name };\n },\n async open(content) { importSave(content.files[0].data); },\n });\n}\n```\n\nHandlers registered in `setup()` are removed with the module; `registerType` returns an unregister\nfunction for anything dynamic. `ctx.navigate(modeId)` switches the host to another module's route\n(the Workshop uses it to jump into the game after `open`); an unknown id is ignored. Details:\n**workshop-system**.\n\n## Bridging scene ↔ panel\n\nThe scene creates its controller at `mount` (needs the canvas), but panels render before that. Share\nit with a one-slot observable and read it with `useSyncExternalStore`:\n\n```ts\nexport function createControllerBox<T>() {\n /* get/set/subscribe */\n}\n// panel: const controller = useSyncExternalStore(box.subscribe, box.get); if (!controller) return <Loading/>;\n```\n\nFor the module's controller React context use the shared factory instead of hand-writing it:\n\n```ts\nexport const [BoardControllerProvider, useBoardController] =\n createControllerContext<BoardController>(\"BoardController\"); // from @idosgames/react\n```\n\n## Layout and boundaries\n\nA module is one folder, `src/modules/<id>/`. It imports only its own files, the game's shared code\nin `src/shared/` and npm packages — never another module's files or host files. Talk to other\nmodules through the shared `ctx.client` (durable state) and `ctx.events` (live signals, typed\ntopics `<module-id>:<event>@<major>` declared in `module.meta.json`). A module shipped in the catalog is fully self-contained — it never uses\n`src/shared/`, so it installs into any game; a game's own module may use it, and its shared code is\ncopied into it when it is published for other creators.\n\n```\nsrc/modules/<id>/\n index.ts export { <camelCaseId>Module } from \"./module\";\n module.ts defineModule({ … })\n module.meta.json manifest: type (template|feature), summary, provides, tags, version, author\n components/ game/ data/ react/ — as needed\n```\n\nEvery module carries its own `module.meta.json` (the `ModuleManifest` shape), including a game's own\nmodules. The full standard — where a new feature goes, file size, documentation — is\n**idosgames-project-structure**.\n\n## Dependencies\n\nDeclare only the module's UNIQUE deps (e.g. `phaser` for idle-rpg, `three` for board/voxel) plus the\nshared baseline (react, @idosgames/*). The platform pins shared libs identically across modules; two\nmodules must not request different versions of one package (the build allowlist rejects it).\n\nStudy a real module via `get_module {id}` (MCP) before writing a new one.\n",
4
+ "content": "---\nname: idosgames-module-contract\ndescription: >-\n Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk:\n the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route\n registration, and the shared controller-box bridge between an imperative scene and React panels.\n Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg,\n voxelcraft, mini-game, store, character, workshop…), or asks about defineModule,\n registerScene/registerPanel/registerRoute, ctx.effect / ctx.every (background work: badges, polling),\n ctx.features (register / open / setBadge / setAvailable),\n ctx.modes,\n activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic,\n ctx.content / ContentTypeHandler (letting the Workshop publish and open the game's content),\n ctx.multiplayer / registerGame (playing online through the lobby's rooms system),\n ctx.lobby / setHero (a game's showcase on the lobby's home — its character on a pedestal + \"Play\"),\n ctx.navigate, the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention.\n---\n\n# The module contract (@idosgames/module-sdk)\n\nA module is a manifest the host installs once. It exports `{camelCase(id)}Module` from its\n`index.ts` (e.g. `board-game` → `boardGameModule`, `idle-rpg` → `idleRpgModule`) — the host seeder\nderives the import name from the id, so this convention is required.\n\n```ts\nimport { defineModule } from \"@idosgames/module-sdk\";\n\nexport const boardGameModule = defineModule({\n id: \"board-game\",\n meta: { name: \"Board Game\", type: \"game\", genre: \"board\", engine: \"three\" }, // engine: three|phaser|dom\n setup(ctx) {\n // ctx.client (shared, authed) · ctx.events (typed cross-module bus) · ctx.sharedUi · ctx.surface\n ctx.registerScene(createScene(box)); // rendered engine scene (optional)\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: RootPanel }); // React UI (optional)\n ctx.registerRoute({ id: \"board-game\", label: \"Board\", icon: \"🎲\" }); // a mode → the lobby's \"Play\"\n },\n});\n```\n\nTwo kinds of module plug into the base (sign-in and the lobby, from `@idosgames/app-shell`):\n\n- a **game** (`meta.type: \"game\"`) registers a route: the lobby's \"Play\" launches it on the whole\n screen with a small \"To the lobby\" button over it (none with `ownExit: true` — the game leads back\n by itself, voxelcraft's pause menu); with `inLobby: true` on the route (idle-rpg) it\n instead lives INSIDE the lobby — the base keeps its balances and tabs around the game through the\n host's `header`/`footer` panel slots (the canvas and every other slot are laid out between them,\n so a game needs nothing for it) and the app starts in it;\n- a **system** (`meta.type: \"app\"`) registers a feature — its screen becomes a lobby tab:\n `ctx.features.register({ id, label, icon, group, order, primary?, available?, Screen })`. A tab\n that depends on title data registers with `available: false` and calls\n `ctx.features.setAvailable(id, ok, reason)` once it knows (details: **idosgames-ui-kit**).\n Give `label` as a function — `label: () => t(\"title\")` — so the tab follows the player's language\n switch without a reload (a plain string stays as registered).\n\n`ctx.modes.list()` / `current()` / `subscribe()` — every mode (id, label, icon, module, module type),\nfilled once all modules are installed (read it at render time, not inside `setup()`).\n`ctx.navigate(id)` switches (`\"lobby\"` is the base); called inside `setup()` it picks the mode the app\nstarts in. `ctx.features.open(id, args)` from a game (the lobby not on screen) switches to the lobby\nand opens that tab there.\n\n`ctx.lobby` — the base's lobby as seen by a game. A game NEVER draws a lobby of its own (no own\n\"Play\" screen, no own menu of creation screens): sign-in, balances, profile and every system are the\nbase's, and the game plugs into it —\n\n- `ctx.lobby.setHero({ mode, mount(host, stage), view(), subscribe(fn), beforePlay? })` puts its\n showcase on the lobby's home (\"Play\" tab), Fortnite-style: `mount` draws into an element filling\n the home behind the lobby's interface (any engine; stand the figure in `stage.spot()` — CSS px of\n the host, read it every frame), returns the cleanup; `view()` describes what the lobby lays out\n with the UI kit around it — `title` / `subtitle` under the figure, `actions` there (\"Edit\",\n \"Emote\" with a `menu`), and the `play` card next to the big \"Play\" (`caption`, `title`, `actions`,\n `more` for its \"⋯\"). The lobby's \"Play\" switches to `mode`. Reference: voxelcraft `src/lobby/hero.ts`;\n- the game's own sections (worlds, character editor, items…) are ordinary features\n (`ctx.features.register`, screens on the UI kit) — tabs next to the shop and the quests;\n- `ctx.lobby.available()` / `open()` — \"To the lobby\" in the game's pause menu (hide it when false).\n Works before the game's scene is mounted: keep what the tabs and the hero need outside the scene\n (voxelcraft: `src/hub.ts`) and hand the choice made there to the scene when it starts.\n\n`ctx.multiplayer` — a game that plays online registers itself (`registerGame({ mode, topology,\nchatMode, maxPlayers, joinInProgress, settings, start(room) })`) and takes the room the lobby's\nrooms system hands it on \"Play\"; it never creates or joins rooms (skill **multiplayer-realtime**).\n\nOptional static field: `sharedUi: { provides?: SharedUiRole[]; requires?: SharedUiRole[] }` — see\n\"Shared chrome\" below.\n\n`meta.type` is `game | app | ai-app`; `meta.engine` is `three | phaser | dom` (`dom` = no renderer —\na pure React/DOM app module). `setup` is called ONCE with `ctx: ModuleContext`.\n\n## EngineScene (the rendered part)\n\nA scene mounts into a bare `HTMLElement` (framework-free — this is why a vanilla Three game like\nvoxelcraft fits). The host's Mode Router drives it; only the active mode runs.\n\n```ts\nconst scene: EngineScene = {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext) {\n controller = new Controller(ctx.host);\n box.set(controller);\n },\n activate() {\n controller?.setRunning(true);\n }, // became the active mode → resume RAF\n suspend() {\n controller?.setRunning(false);\n }, // hidden → stop RAF (invariant: only active ticks)\n destroy() {\n controller?.destroy();\n }, // permanent teardown\n};\n```\n\n`mount` takes a context object (not a bare element) so it can grow — e.g. a host-shared renderer in\nthe composition era — without breaking the contract. A scene MUST stop its RAF on `suspend`.\n\n### Loading — never a still screen\n\nA scene's FIRST start goes behind the app's loading screen by itself: the host shows it (themed,\n\"Loading «<mode>»…\"), lets it paint, THEN calls `mount` — so heavy synchronous work in `mount`\n(generating terrain, building meshes) never looks like a frozen app. The screen goes away after the\nfirst frame, or — when the scene has more to do — when its optional `ready(): Promise<void>` resolves\n(voxelcraft: the last played world restored from storage). Never draw your own start-up spinner.\n\nLong work AFTER the start goes through `ctx.loading` — the same screen:\n\n- `await ctx.loading.track(fetchWorld(), \"Loading the world…\")` — async work;\n- `await ctx.loading.run(() => buildWorld(seed), \"Building the world…\")` — work that BLOCKS the page:\n the screen is shown and painted first (calling heavy code right after `begin()` freezes the page\n before the screen appears);\n- `const task = ctx.loading.begin(\"…\"); task.progress(0.4, \"Textures…\"); task.done();` — with progress.\n\n### Notifications — `ctx.notifications`\n\nThe app's one system of toasts and alert windows (the UI kit draws them, themed): never draw a banner\nof your own. `ctx.notifications.toast({ level, title, message, icon, image, source, key, actions })`\n— `level`: `info | success | warning | error | reward`; `source` defaults to the module's id (the\ntoast shows that screen's name and a tap opens it). `await ctx.notifications.alert({ level, title,\nmessage, actions: [{ id: \"go\", label: \"Open\", primary: true, openFeature: \"season\" }, { label: \"Later\" }] })`\nresolves with the pressed action's id (`null` — closed). React screens: `useNotify()` from the kit.\n\nA module that registers a scene MUST also publish its debug surface — `ctx.exposeToAgent({ state,\nactions, describeActions })` — and must NOT gate controls on Pointer Lock (unavailable in the\npreview's cross-origin iframe). Nothing inside a `<canvas>` is observable from the DOM, so without\nthe surface neither the AI Coder nor a human reviewer can tell what the game is doing. See the\n`idosgames-agent-debug-surface` skill.\n\n### Videos on idosgames.com — `focus()`, `audio()`, `ctx.clips.mark()`\n\nOn idosgames.com the player records the game: **Clip** (the last 30/60 s, always kept in the\nbackground), **Record** (start/stop), **Best moments** (made automatically), then trims, picks\nYouTube 16:9 / TikTok 9:16 / square and shares. The host (`@idosgames/app-shell`) records the active\nscene's `<canvas>` by itself — no code is needed for a basic video. Three optional touches make the\nvideos good; add them to every canvas game:\n\n```ts\n// in the scene object\nfocus() { // where the action is, 0–1 on the canvas — the vertical cut follows it\n const p = worldToScreen(player.position); // your own projection\n return { x: p.x / canvas.width, y: p.y / canvas.height };\n},\naudio() { // the game's sound, so videos are not silent\n return musicAndSfxDestination.stream; // a MediaStreamAudioDestinationNode your\n}, // master gain ALSO feeds (keep the speakers)\n\n// in setup() / gameplay — bright moments become the player's \"Best moments\" clips\nctx.clips.mark(\"boss_down\", 8); // weight 1–10: a boss 8, a record 6, a level-up 3, a silly death 4\n```\n\n- A first-person game needs no `focus` (the action is in the centre). Do not call it per frame —\n the host samples it a few times a second.\n- `audio()` returns `null` until your AudioContext exists (it is created on the first click) — fine,\n the host asks again. Route every sound through ONE master node and tap that (VoxelCraft:\n `systems/Audio.ts` `output()` / `recordStream()`).\n- Mark real moments only — the session keeps the 3 heaviest; close marks merge and add up.\n- An analytics event the publisher flagged as a highlight (`Analytics.Events[name].Highlight`) and\n loud voice chat are marks too, without any code.\n- DOM HUD panels are NOT in the video (only the canvas) — a clean video reads better in a feed.\n- Reference: `modules/voxelcraft` (`scene.ts` `audio`, `game/wire.ts` marks).\n\n### `capture()` — one still frame\n\nA scene may also answer the platform's still-frame request through the optional\n`capture?(): Promise<Blob | null>`. The host (`@idosgames/app-shell`) wires the request itself —\na module only has to return a picture of its active scene.\n\n**Implement it in any scene that renders to a canvas.** Without it the host falls back to reading\nthe `<canvas>` from outside, and for WebGL that is a trap: a renderer created WITHOUT\n`preserveDrawingBuffer` (the default, and the right default — the flag costs memory and a copy every\nframe) has already cleared its buffer by the time anyone reads it, so `toBlob()` returns a **fully\nblack** image and reports nothing. The host rejects single-colour frames, so the player gets the\ngame's cover instead of their moment.\n\nRender and read in the SAME tick:\n\n```ts\n// three.js\ncapture() {\n return new Promise((resolve) => {\n renderer.render(scene, camera); // draw now…\n renderer.domElement.toBlob(resolve, \"image/png\"); // …and read before the buffer is cleared\n });\n},\n\n// Phaser — use its own snapshot, it handles the timing\ncapture() {\n return new Promise((resolve) => {\n setTimeout(() => resolve(null), 1500); // no frame came (context lost) → answer \"no\", don't hang\n game.loop.wake(); // a sleeping loop never draws again → snapshot never fires\n game.renderer.snapshot((image) => {\n if (!(image instanceof HTMLImageElement)) return resolve(null);\n const c = document.createElement(\"canvas\");\n c.width = image.naturalWidth || image.width;\n c.height = image.naturalHeight || image.height;\n c.getContext(\"2d\")?.drawImage(image, 0, 0);\n c.toBlob(resolve, \"image/png\");\n });\n });\n},\n```\n\nAlways answer — with a Blob or `null`. A `capture()` that never settles makes the site wait out its\ntimeout before it falls back, and the player stares at a spinner.\n\nReturn `null` when there is nothing worth showing (loading, no scene yet) — the site then uses the\ncover. Only the scene is captured, not the React panels over it: a shot for a feed reads better\nwithout the HUD. DOM-only modules (no canvas) simply omit `capture`. Working references:\n`modules/board-game` and `modules/voxelcraft` (three), `modules/idle-rpg` (Phaser).\n\n## UiPanel (the React part)\n\nPanels are React components the host renders inside its provider stack (client + status already\nprovided). Use `@idosgames/react` hooks (`useIDosGamesClient`, `useUserState`) — do NOT re-create\nproviders.\n\n- `slot`: `hud | sidebar | overlay | modal`.\n- `activeOnly` (default true): show only while this module's mode is active. Set `false` for shared\n chrome that stays across every mode (e.g. a persistent HUD).\n- `overlay`/`sidebar` layers sit between the shared HUD and the nav (the host measures both), so a\n panel positioned `absolute` inside its layer never ends up under them. Things drawn outside the\n layers use `var(--idos-safe-top, 0px)` / `var(--idos-safe-bottom, 0px)`.\n- A `hud` panel's wrapper is `display: contents`: the panel is a direct flex child of the HUD row\n (can `flex: 1`) and must set `pointer-events: auto` only on its controls.\n\n## Shared chrome (`sharedUi`)\n\nRoles: `currency-bar` (virtual-currency balances), `wallet` (crypto wallet entry — `LazyWalletPanel`),\n`status` (the `useStatus()` line), `account` (Log out + player ID), `menu` (the menu of features),\n`modes` (the switch between modes). The base takes `currency-bar`, `wallet`, `account`, `menu` and\n`modes` — its lobby draws them; `status` stays with each game. A module that draws one for every\nmode declares `sharedUi: { provides: [...] }` on the module object — statically, so the host resolves\nowners before any `setup()` and the answer never depends on module order.\n\n**Rule for templates: yield every role you draw yourself.** Read it once in `setup()` and close over\nthe answer (it is fixed for the session):\n\n```ts\nsetup(ctx) {\n const chrome = {\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n balances: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n };\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: makeRootPanel(box, chrome) });\n}\n```\n\nHide only the SHARED pieces — genre UI (the board's stage/tile/cycle chips) always stays. Never\nwrap your UI in your own `<StatusProvider>`: the host provides ONE, and a nested one would swallow\nyour messages so the shared status line never sees them. A module that needs a role but does not\ndraw it declares `sharedUi: { requires: [\"currency-bar\"] }`. `ctx.sharedUi.ownerOf(role)` names the\nprovider (or `null`). A full-screen mode (its own bottom tab bar) that shows the player ID and\n\"Log out\" in its own header declares `sharedUi: { drawsOwn: [\"account\"] }` and draws them only while\n`shouldDraw(\"account\")`: the host then hides its floating bottom-left copy while that mode is on\nscreen (it would cover the tab bar) and keeps it in the other modes. `drawsOwn` takes no role —\nwith the base installed the lobby draws the account and the mode hides its copy.\n\n## Events (`ctx.events`)\n\nTyped topic tokens — `defineTopic(\"<your-id>:<event>@1\", shape({ … }))` from\n`@idosgames/module-sdk`; emit only your own namespace; to listen to another module, copy its topic\nand payload descriptor from the catalog into your own `defineTopic` (never import it); declare both\nin `module.meta.json` (`events.emits` / `events.listens`). **Subscribe in `setup()`**, not in a panel\neffect — `activeOnly` panels unmount with their mode and miss events; store what arrives in a small\nstore the panel reads. Full rules and examples: **idosgames-compose-modules** (\"Signals between\nmodules\"). The string overloads (`emit(\"x\", …)`) are deprecated.\n\n## Content types (`ctx.content`) and switching modes (`ctx.navigate`)\n\nA game whose players make things (worlds, levels, skins) registers a **content type handler** so the\nshared Workshop module can publish and open them — the Workshop never imports the game:\n\n```ts\nsetup(ctx) {\n ctx.content.registerType({\n type: \"voxelcraft.world\", // = a key of Workshop.ContentTypes in the title config\n label: \"VoxelCraft world\",\n icon: \"⛏️\",\n modeId: \"voxelcraft\", // route to switch to after open()\n async listLocal() { return saves.map((s) => ({ id: s.id, name: s.name, updatedAt: s.at })); },\n async capture(localId) { // roles/MIME types must fit the title's content type config\n return { files: [{ role: \"main\", contentType: \"application/json\", data: json }],\n thumbnail: { contentType: \"image/webp\", data: webp }, suggestedTitle: name };\n },\n async open(content) { importSave(content.files[0].data); },\n });\n}\n```\n\nHandlers registered in `setup()` are removed with the module; `registerType` returns an unregister\nfunction for anything dynamic. `ctx.navigate(modeId)` switches the host to another module's route\n(the Workshop uses it to jump into the game after `open`); an unknown id is ignored. Details:\n**workshop-system**.\n\n## Bridging scene ↔ panel\n\nThe scene creates its controller at `mount` (needs the canvas), but panels render before that. Share\nit with a one-slot observable and read it with `useSyncExternalStore`:\n\n```ts\nexport function createControllerBox<T>() {\n /* get/set/subscribe */\n}\n// panel: const controller = useSyncExternalStore(box.subscribe, box.get); if (!controller) return <Loading/>;\n```\n\nFor the module's controller React context use the shared factory instead of hand-writing it:\n\n```ts\nexport const [BoardControllerProvider, useBoardController] =\n createControllerContext<BoardController>(\"BoardController\"); // from @idosgames/react\n```\n\n## Layout and boundaries\n\nA module is one folder, `src/modules/<id>/`. It imports only its own files, the game's shared code\nin `src/shared/` and npm packages — never another module's files or host files. Talk to other\nmodules through the shared `ctx.client` (durable state) and `ctx.events` (live signals, typed\ntopics `<module-id>:<event>@<major>` declared in `module.meta.json`). A module shipped in the catalog is fully self-contained — it never uses\n`src/shared/`, so it installs into any game; a game's own module may use it, and its shared code is\ncopied into it when it is published for other creators.\n\n```\nsrc/modules/<id>/\n index.ts export { <camelCaseId>Module } from \"./module\";\n module.ts defineModule({ … })\n module.meta.json manifest: type (template|feature), summary, provides, tags, version, author\n components/ game/ data/ react/ — as needed\n```\n\nEvery module carries its own `module.meta.json` (the `ModuleManifest` shape), including a game's own\nmodules. The full standard — where a new feature goes, file size, documentation — is\n**idosgames-project-structure**.\n\n## Dependencies\n\nDeclare only the module's UNIQUE deps (e.g. `phaser` for idle-rpg, `three` for board/voxel) plus the\nshared baseline (react, @idosgames/*). The platform pins shared libs identically across modules; two\nmodules must not request different versions of one package (the build allowlist rejects it).\n\nStudy a real module via `get_module {id}` (MCP) before writing a new one.\n",
5
5
  "references": []
6
6
  }
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "leaderboard-system",
3
3
  "description": "Build a leaderboard / ranking system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.leaderboard (LeaderboardService): load leaderboard definitions, load a leaderboard's top list (+ batch), load the player's own progress (+ batch), submit a score (direct or from a trigger source, + batch), claim the cycle/instance-end rank reward (+ batch), claim a milestone reward (+ batch), claim every reward across many leaderboards in one call, fetch a combined top-list+progress overview (batch), and load a friends-filtered leaderboard view. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants ranking screens, PvP/score leaderboards, seasonal or cyclic ladders, rank-reward or milestone-reward UIs, or otherwise touches client.leaderboard, LeaderboardService, LeaderboardDefinitions, UserLeaderboardProgress, GetMyProgressResponse, or leaderboard cycles/brackets — even if they don't name the module explicitly.",
4
- "content": "---\nname: leaderboard-system\ndescription: >-\n Build a leaderboard / ranking system in a game on the iDosGames TypeScript\n SDK (@idosgames/core) via client.leaderboard (LeaderboardService): load\n leaderboard definitions, load a leaderboard's top list (+ batch), load the\n player's own progress (+ batch), submit a score (direct or from a trigger\n source, + batch), claim the cycle/instance-end rank reward (+ batch), claim\n a milestone reward (+ batch), claim every reward across many leaderboards in\n one call, fetch a combined top-list+progress overview (batch), and load a\n friends-filtered leaderboard view. Use this whenever the user is working in\n the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants\n ranking screens, PvP/score leaderboards, seasonal or cyclic ladders,\n rank-reward or milestone-reward UIs, or otherwise touches client.leaderboard,\n LeaderboardService, LeaderboardDefinitions, UserLeaderboardProgress,\n GetMyProgressResponse, or leaderboard cycles/brackets — even if they don't\n name the module explicitly.\n---\n\n# Leaderboard system (iDosGames TS SDK)\n\nThe Leaderboard module ranks players against each other on a numeric score,\ninside repeating **cycles** (Cyclic schedule) or one-off/chained **instances**\n(Scheduled/Chained schedule). Everything is **server-authoritative**: the\nclient submits a score or asks to claim a reward, the backend validates and\naggregates it, and the SDK mirrors the confirmed result into a local cache\nyour UI reads. You never rank players or compute cycle math yourself.\n\nThis skill is for **using** the production `LeaderboardService`, not for\nporting or extending it. If a call is rejected, that's the backend enforcing a\nrule (leaderboard disabled, gate not met, nothing to claim) — surface the\nerror, don't try to reproduce the check client-side.\n\n## The two data shapes\n\nKeep these straight; every recipe below is just moving between them.\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n leaderboards: schedule mode, score aggregation rule, rank rewards,\n milestones, gate, bracket size. Fetched with `getDefinitions()`.\n2. **Player progress** (state, per player, per cycle/instance) — this\n player's current score, rank, earned-this-cycle amount, claimed\n milestones, and whether a rank reward is waiting. Fetched with\n `getMyProgress()` / `getProgressBatch()` / `getOverviewBatch()`.\n\nA leaderboard is identified by a string `LeaderboardID`. Progress is cached\nper **cycle/instance doc**, keyed internally as `` `${titleID}_${leaderboardID}` ``\nfor `Cyclic` leaderboards (with a `:{instanceKey}` suffix on the backend for\nScheduled/Chained instances) — you never build that key yourself, just read\n`client.data.user.state?.Leaderboard?.ProgressByCycle` (see\n[Reading state](#reading-state-and-reacting-to-changes)).\n\nTwo reward tracks per leaderboard, don't conflate them:\n\n- **Rank (cycle) reward** — one payout per cycle/instance based on where you\n land in `RankRewards` when it ends. Claimed with `claimCycleReward`.\n- **Milestones** — score-threshold rewards inside the current cycle/instance\n (e.g. \"earn 1000 points this week\"), the same `MilestoneDefinition`\n primitive used by Quest/TimedEvent/CoopEvent. Claimed with `claimMilestone`.\n\nFor the full field-by-field shape of Definitions and state (score aggregation\nmodes, schedule modes, rank-reward ranges, milestone claim modes, grace\ncycles/instances), read\n[references/data-model.md](references/data-model.md). You do **not** need it\nto call the methods — only to drive richer UI off the config.\n\nCost/reward objects use the SDK-wide shared `ResourceGrant` /\n`ResourceOperation` types — see `.claude/skills/currency-system/SKILL.md`.\n`Gate.MinPremiumTier` and other premium-tier gating are documented in\n`.claude/skills/premium-system/SKILL.md`.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst leaderboard = client.leaderboard; // the LeaderboardService\n```\n\nEvery leaderboard method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is\none `client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch\non `result.ok` before touching `result.data`. `reason` is one of `\"client\"`\n(bad local args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint\nagain inside the throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. `\"Leaderboard '{id}' is\ndisabled\"`, `\"No unclaimed reward\"`, `\"Reward already claimed\"`).\n\n| Method | Purpose | `data` on success |\n| -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------ |\n| `getDefinitions()` | Load the title's leaderboard catalog (config). | `LeaderboardDefinitions` |\n| `getLeaderboard(leaderboardID)` | Load one leaderboard's current top list. | `GetLeaderboardResponse` (`TopUsers`, `CycleVersion`) |\n| `getMyProgress(leaderboardID)` | Load this player's progress in the current cycle/instance. | `GetMyProgressResponse` |\n| `submitScore(leaderboardID, score)` | Submit a raw score directly. | `SubmitScoreResponse` (`NewScore`, `CycleVersion`) |\n| `submitScoreFromSource(leaderboardID, sourceType, outcome?, params?, rollMultiplier?)` | Submit a score derived from a configured `ScoreSources` trigger. | `SubmitScoreResponse` |\n| `claimCycleReward(leaderboardID)` | Claim the ended cycle/instance's rank reward. | `ClaimCycleRewardResponse` (`Rank`, `Resources`) |\n| `claimMilestone(leaderboardID, milestoneID)` | Claim one reached milestone. | `ClaimLeaderboardMilestoneResponse` |\n| `getFriendsLeaderboard(leaderboardID)` | Load the top list filtered to the player's friends. | `GetLeaderboardResponse` |\n| `getLeaderboardsBatch(leaderboardIDs)` | Load top lists for several leaderboards in one call. | `BatchItemResult<GetLeaderboardResponse>[]` |\n| `getProgressBatch(leaderboardIDs)` | Load this player's progress on several leaderboards in one call. | `BatchItemResult<GetMyProgressResponse>[]` |\n| `claimCycleRewardsBatch(leaderboardIDs)` | Claim the rank reward on several leaderboards in one call. | `BatchItemResult<ClaimCycleRewardResponse>[]` |\n| `claimMilestonesBatch(milestones)` | Claim several explicit (leaderboard, milestone) pairs in one call. | `BatchItemResult<ClaimLeaderboardMilestoneResponse>[]` |\n| `submitScoresBatch(scores)` | Submit scores to several leaderboards in one call. | `BatchItemResult<SubmitScoreResponse>[]` |\n| `claimAllRewardsBatch(leaderboardIDs)` | Claim everything reachable (rank reward + all milestones) across several leaderboards. | `ClaimAllRewardsResult[]` |\n| `getOverviewBatch(leaderboardIDs)` | Load top list + progress together for several leaderboards. | `LeaderboardOverview[]` |\n\n`submitScoreFromSource` mirrors the confirmed score the same way `submitScore`\ndoes; use it when the score comes from a game event configured on the\nleaderboard's `ScoreSources` (a `TriggerSource`, same primitive used by\nTimedEvent/CoopEvent) rather than a value your client computed — pass the\n`sourceType`/`outcome`/`params` that identify which trigger fired, and\n`rollMultiplier` to scale it (defaults to 1). The backend only accepts\nclient-safe source types here — board/quest/store/marketplace/rank-reward\nsources are server-only and are rejected with `\"Source '{type}' cannot be\ncalled from client.\"`. It's also a **no-op that still returns `ok: true`**\n(`NewScore: 0, CycleVersion: 0`) when the leaderboard isn't `Sum`-aggregated,\nor no configured source matches — don't treat that as an error, and don't\nshow a \"+0\" toast without checking `NewScore > 0`.\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. Rewards ride along in\n`data.Resources` (single calls) and are already applied to cached balances;\nfor `claimCycleRewardsBatch`, `claimMilestonesBatch`, and\n`claimAllRewardsBatch`, each successful item's `Resources` is applied\nper-item as the batch result is processed.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values — that way every\nscreen stays consistent no matter which code path changed things.\n\n```ts\n// Definitions (cached after getDefinitions()):\nimport type { LeaderboardDefinitions } from \"@idosgames/core\";\nconst defs =\n client.data.config.getSection<LeaderboardDefinitions>(\"Leaderboard\");\n\n// Player progress, keyed by an internal `${titleID}_${leaderboardID}` cycle-doc id —\n// don't build the key yourself, look it up by matching LeaderboardID if you need to\n// scan, or just re-call getMyProgress/getProgressBatch and read the response directly.\nconst byCycle = client.data.user.state?.Leaderboard?.ProgressByCycle ?? {};\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `leaderboard:definitionsLoaded` → `LeaderboardDefinitions`\n- `leaderboard:loaded` → `GetLeaderboardResponse`\n- `leaderboard:loadedBatch` → `BatchItemResult<GetLeaderboardResponse>[]`\n- `leaderboard:myProgressLoaded` → `GetMyProgressResponse`\n- `leaderboard:myProgressLoadedBatch` → `BatchItemResult<GetMyProgressResponse>[]`\n- `leaderboard:scoreSubmitted` → `SubmitScoreResponse`\n- `leaderboard:scoresSubmittedBatch` → `BatchItemResult<SubmitScoreResponse>[]`\n- `leaderboard:cycleRewardClaimed` → `ClaimCycleRewardResponse`\n- `leaderboard:cycleRewardsClaimedBatch` → `BatchItemResult<ClaimCycleRewardResponse>[]`\n- `leaderboard:milestoneClaimed` → `ClaimLeaderboardMilestoneResponse`\n- `leaderboard:milestonesClaimedBatch` → `BatchItemResult<ClaimLeaderboardMilestoneResponse>[]`\n- `leaderboard:allRewardsClaimedBatch` → `ClaimAllRewardsBatchResponse`\n- `leaderboard:overviewLoadedBatch` → `GetOverviewBatchResponse`\n- `leaderboard:friendsLoaded` → `GetLeaderboardResponse`\n\nThe coarse `user:leaderboardUpdated` (and `user:anyUpdated`) also fire on any\nleaderboard cache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"leaderboard:cycleRewardClaimed\", (r) => {\n console.log(`${r.LeaderboardID}: rank ${r.Rank} reward claimed`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Golden path: submit a score, check progress, claim the rank reward\n\n```ts\nawait client.leaderboard.getDefinitions();\n\nconst sub = await client.leaderboard.submitScore(\"weekly_score\", 150);\nif (!sub.ok) return showError(sub.error);\nsub.data.NewScore; // score after aggregation (Sum/BestScore/BestTime/LastValue)\nsub.data.CycleVersion; // which cycle this landed in\n\nconst progress = await client.leaderboard.getMyProgress(\"weekly_score\");\nif (!progress.ok) return showError(progress.error);\nif (progress.data.HasUnclaimedReward) {\n const claim = await client.leaderboard.claimCycleReward(\"weekly_score\");\n if (!claim.ok) return showError(claim.error); // e.g. \"No unclaimed reward\"\n claim.data.Rank; // rank that earned the reward\n claim.data.Resources; // already applied to cached balances\n}\n```\n\n`submitScore`'s effect on `NewScore` depends on the leaderboard's\n`ScoreAggregation`: `Sum` adds to the running total, `BestScore`/`BestTime`\nonly replace if better, `LastValue` always overwrites. The SDK doesn't\nreplicate this math client-side — trust `NewScore` from the response. A\n`submitScore` call with `score <= 0` is rejected client-side with\n`reason: \"client\"` before any request is sent.\n\n### Show a top list with the player's own progress (overview batch)\n\n```ts\nconst res = await client.leaderboard.getOverviewBatch([\n \"weekly_score\",\n \"season_pvp\",\n]);\nif (!res.ok) return showError(res.error);\n\nfor (const entry of res.data) {\n if (entry.Leaderboard?.Success) {\n entry.Leaderboard.Data?.TopUsers; // ranked entries for this board\n }\n if (entry.Progress?.Success) {\n entry.Progress.Data?.CurrentScore;\n entry.Progress.Data?.LastKnownRank;\n entry.Progress.Data?.NextMilestone; // next unreached MilestoneDefinition\n }\n}\n```\n\nUse `getOverviewBatch` for a multi-board hub screen instead of pairing\n`getLeaderboardsBatch` + `getProgressBatch` yourself — it's one round trip and\neach entry already pairs the two results by `LeaderboardID`.\n\n### Claim everything at once across several leaderboards\n\n```ts\nconst res = await client.leaderboard.claimAllRewardsBatch([\n \"weekly_score\",\n \"season_pvp\",\n]);\nif (!res.ok) return showError(res.error);\n\nfor (const entry of res.data) {\n if (entry.CycleReward?.Success) applyCycleReward(entry.LeaderboardID);\n for (const m of entry.Milestones ?? []) {\n if (m.Success) applyMilestone(entry.LeaderboardID, m.Data?.MilestoneID);\n else showItemError(entry.LeaderboardID, m.Error);\n }\n}\n```\n\nThis is the one-tap \"claim all\" button: it claims each leaderboard's rank\nreward (if any) and every reachable milestone, per-leaderboard. A leaderboard\nwith nothing to claim simply reports `CycleReward: null`/absent and an empty\n`Milestones` list — not an error.\n\n### Edge case: milestones during a grace window after cycle end\n\n```ts\nconst progress = await client.leaderboard.getMyProgress(\"weekly_score\");\nif (progress.ok) {\n for (const grace of progress.data.ClaimableGraceCycles ?? []) {\n // A past cycle/instance still inside its MilestoneClaimGraceHours window.\n for (const milestoneID of grace.ClaimableMilestoneIDs ?? []) {\n const claim = await client.leaderboard.claimMilestone(\n \"weekly_score\",\n milestoneID,\n );\n if (!claim.ok) showError(claim.error); // e.g. window expired mid-request\n }\n }\n}\n```\n\nWhen a leaderboard's `MilestoneClaimMode` is `AfterEventEnd` (or\n`FeaturedAfterEnd` for a featured milestone), milestones reached during a\ncycle/instance can't be claimed until it closes, and only within\n`MilestoneClaimGraceHours` afterward. `GetMyProgressResponse\n.ClaimableGraceCycles` lists any such still-open past cycles/instances (see\n`LeaderboardGraceCycle` in the reference) — check it in addition to the\ncurrent cycle's own milestones, since the current cycle's `NextMilestone` only\ndescribes the next _unreached_ one, not ones already earned but unclaimed\nunder `AfterEventEnd`/`FeaturedAfterEnd`.\n\n### Batch score submission from multiple game systems\n\n```ts\nconst res = await client.leaderboard.submitScoresBatch([\n { LeaderboardID: \"weekly_score\", Score: 150 },\n { LeaderboardID: \"season_pvp\", Score: 20 },\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (item.Success) applyOk(item.Id, item.Data?.NewScore);\n else showItemError(item.Id, item.Error); // e.g. this board is disabled\n}\n```\n\nBatch results are **partial-aware**: the outer `res.ok` tells you the call\nran; each element's `Success`/`Error` tells you whether that submission\napplied. Use this when one game action (e.g. finishing a match) should post a\nscore to several leaderboards at once instead of firing N separate\n`submitScore` calls.\n\n## Gotchas\n\n- **Batch atomicity is conditional, not blanket.** Unlike some other modules'\n batch actions, Leaderboard batches are only a single all-or-nothing\n transaction for the subset of targeted leaderboards that are **Cyclic and\n not bracketed**. Any leaderboard using `BracketSize > 0` or a\n `Scheduled`/`Chained` schedule is processed as an independent per-item\n operation inside the same batch call — each such item can succeed or fail\n on its own, and a failure there does **not** roll back the atomic cyclic\n group (nor vice versa). Don't assume \"the whole batch failed\" or \"the whole\n batch is one transaction\" for mixed leaderboard sets.\n- **Batch input is deduped, clamped, and silently overflowed.** All\n `*Batch`/array-of-IDs methods (and `claimMilestonesBatch`'s pairs,\n `submitScoresBatch`'s score refs) trim whitespace, drop empty entries,\n dedupe (first occurrence wins), and cap at **50 items per call** —\n anything past 50 is silently dropped and never appears in the results\n array. The SDK does not pre-validate or chunk this for you; if you can\n exceed 50 in one screen action, split into multiple calls yourself.\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations — resubmitting the same score\n twice (e.g. a double-tapped \"Submit\" button) posts it twice under `Sum`\n aggregation. Disable the control while a call is in flight. (Firing the same\n endpoint again within the throttle window, default 500 ms for this module,\n is rejected with `reason: \"throttled\"` rather than duplicated, but don't\n rely on that for correctness.)\n- **`NewScore` reflects aggregation, not addition.** Don't assume\n `NewScore === previous + submitted`; only `Sum` behaves that way.\n `BestScore`/`BestTime` may leave `NewScore` unchanged if the submission\n didn't beat the record. Always read `NewScore` from the response rather than\n computing it locally.\n- **Cache resets on a new cycle.** When a submitted score's `CycleVersion` is\n newer than the cached one, the SDK zeroes `ScoreEarnedThisCycle` and\n `ClaimedMilestoneIDs` and clears `BracketID` for that leaderboard — a stale\n UI holding old milestone-claimed state from the prior cycle will look wrong\n until you re-render from the cache after the bump.\n `UnclaimedRewardCycleVersion` is a separate flag driven by\n `HasUnclaimedReward` from `getMyProgress`, not by score submission.\n `claimCycleReward` only clears it, it doesn't reset the rest of the cycle.\n- **\"No unclaimed reward\" / \"Reward already claimed\" are normal outcomes, not\n bugs.** `claimCycleReward` and `claimMilestone` reject with\n `reason: \"server\"` if there's nothing to claim — always check\n `HasUnclaimedReward` (or `NextMilestone` / `HasUnclaimedMilestone`) from\n `getMyProgress` before showing a claim button.\n- **Friends view is a separate read, not a filter you apply.** Use\n `getFriendsLeaderboard`, not `getLeaderboard` — the backend does the\n friends-list filtering server-side; there's no client-side way to derive it\n from the global top list (a friend far outside `TopUsersLimit` wouldn't be\n in that list at all).\n- **Score submission and reward claiming both need auth; reads of\n Definitions/top-list/friends do too.** Every method in this service — reads\n included — requires `client.auth.context`; there's no anonymous leaderboard\n browsing in this SDK.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field, schedule modes (Cyclic vs Scheduled vs Chained instance\nidentity), score aggregation modes, rank-reward ranges, milestone claim\nmodes and grace windows, brackets, and the exact batch atomicity/dedup rules.\n",
4
+ "content": "---\nname: leaderboard-system\ndescription: >-\n Build a leaderboard / ranking system in a game on the iDosGames TypeScript\n SDK (@idosgames/core) via client.leaderboard (LeaderboardService): load\n leaderboard definitions, load a leaderboard's top list (+ batch), load the\n player's own progress (+ batch), submit a score (direct or from a trigger\n source, + batch), claim the cycle/instance-end rank reward (+ batch), claim\n a milestone reward (+ batch), claim every reward across many leaderboards in\n one call, fetch a combined top-list+progress overview (batch), and load a\n friends-filtered leaderboard view. Use this whenever the user is working in\n the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants\n ranking screens, PvP/score leaderboards, seasonal or cyclic ladders,\n rank-reward or milestone-reward UIs, or otherwise touches client.leaderboard,\n LeaderboardService, LeaderboardDefinitions, UserLeaderboardProgress,\n GetMyProgressResponse, or leaderboard cycles/brackets — even if they don't\n name the module explicitly.\n---\n\n# Leaderboard system (iDosGames TS SDK)\n\nThe Leaderboard module ranks players against each other on a numeric score,\ninside repeating **cycles** (Cyclic schedule) or one-off/chained **instances**\n(Scheduled/Chained schedule). Everything is **server-authoritative**: the\nclient submits a score or asks to claim a reward, the backend validates and\naggregates it, and the SDK mirrors the confirmed result into a local cache\nyour UI reads. You never rank players or compute cycle math yourself.\n\nThis skill is for **using** the production `LeaderboardService`, not for\nporting or extending it. If a call is rejected, that's the backend enforcing a\nrule (leaderboard disabled, gate not met, nothing to claim) — surface the\nerror, don't try to reproduce the check client-side.\n\n## The two data shapes\n\nKeep these straight; every recipe below is just moving between them.\n\n1. **Definitions** (config, same for every player) — the title's catalog of\n leaderboards: schedule mode, score aggregation rule, rank rewards,\n milestones, gate, bracket size. Fetched with `getDefinitions()`.\n2. **Player progress** (state, per player, per cycle/instance) — this\n player's current score, rank, earned-this-cycle amount, claimed\n milestones, and whether a rank reward is waiting. Fetched with\n `getMyProgress()` / `getProgressBatch()` / `getOverviewBatch()`.\n\nA leaderboard is identified by a string `LeaderboardID`. Progress is cached\nper **cycle/instance doc**, keyed internally as `` `${titleID}_${leaderboardID}` ``\nfor `Cyclic` leaderboards (with a `:{instanceKey}` suffix on the backend for\nScheduled/Chained instances) — you never build that key yourself, just read\n`client.data.user.state?.Leaderboard?.ProgressByCycle` (see\n[Reading state](#reading-state-and-reacting-to-changes)).\n\nTwo reward tracks per leaderboard, don't conflate them:\n\n- **Rank (cycle) reward** — one payout per cycle/instance based on where you\n land in `RankRewards` when it ends. Claimed with `claimCycleReward`.\n- **Milestones** — score-threshold rewards inside the current cycle/instance\n (e.g. \"earn 1000 points this week\"), the same `MilestoneDefinition`\n primitive used by Quest/TimedEvent/CoopEvent. Claimed with `claimMilestone`.\n\nFor the full field-by-field shape of Definitions and state (score aggregation\nmodes, schedule modes, rank-reward ranges, milestone claim modes, grace\ncycles/instances), read\n[references/data-model.md](references/data-model.md). You do **not** need it\nto call the methods — only to drive richer UI off the config.\n\nCost/reward objects use the SDK-wide shared `ResourceGrant` /\n`ResourceOperation` types — see `.claude/skills/currency-system/SKILL.md`.\n`Gate.MinPremiumTier` and other premium-tier gating are documented in\n`.claude/skills/premium-system/SKILL.md`.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst leaderboard = client.leaderboard; // the LeaderboardService\n```\n\nEvery leaderboard method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is\none `client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch\non `result.ok` before touching `result.data`. `reason` is one of `\"client\"`\n(bad local args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint\nagain inside the throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. `\"Leaderboard '{id}' is\ndisabled\"`, `\"No unclaimed reward\"`, `\"Reward already claimed\"`).\n\n| Method | Purpose | `data` on success |\n| -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------ |\n| `getDefinitions()` | Load the title's leaderboard catalog (config). | `LeaderboardDefinitions` |\n| `getLeaderboard(leaderboardID)` | Load one leaderboard's current top list. | `GetLeaderboardResponse` (`TopUsers`, `CycleVersion`) |\n| `getMyProgress(leaderboardID)` | Load this player's progress in the current cycle/instance. | `GetMyProgressResponse` |\n| `submitScore(leaderboardID, score)` | Submit a raw score directly. | `SubmitScoreResponse` (`NewScore`, `CycleVersion`) |\n| `submitScoreFromSource(leaderboardID, sourceType, outcome?, params?, rollMultiplier?)` | Submit a score derived from a configured `ScoreSources` trigger. | `SubmitScoreResponse` |\n| `claimCycleReward(leaderboardID)` | Claim the ended cycle/instance's rank reward. | `ClaimCycleRewardResponse` (`Rank`, `Resources`) |\n| `claimMilestone(leaderboardID, milestoneID)` | Claim one reached milestone. | `ClaimLeaderboardMilestoneResponse` |\n| `getFriendsLeaderboard(leaderboardID)` | Load the top list filtered to the player's friends. | `GetLeaderboardResponse` |\n| `getLeaderboardsBatch(leaderboardIDs)` | Load top lists for several leaderboards in one call. | `BatchItemResult<GetLeaderboardResponse>[]` |\n| `getProgressBatch(leaderboardIDs)` | Load this player's progress on several leaderboards in one call. | `BatchItemResult<GetMyProgressResponse>[]` |\n| `claimCycleRewardsBatch(leaderboardIDs)` | Claim the rank reward on several leaderboards in one call. | `BatchItemResult<ClaimCycleRewardResponse>[]` |\n| `claimMilestonesBatch(milestones)` | Claim several explicit (leaderboard, milestone) pairs in one call. | `BatchItemResult<ClaimLeaderboardMilestoneResponse>[]` |\n| `submitScoresBatch(scores)` | Submit scores to several leaderboards in one call. | `BatchItemResult<SubmitScoreResponse>[]` |\n| `claimAllRewardsBatch(leaderboardIDs)` | Claim everything reachable (rank reward + all milestones) across several leaderboards. | `ClaimAllRewardsResult[]` |\n| `getOverviewBatch(leaderboardIDs)` | Load top list + progress together for several leaderboards. | `LeaderboardOverview[]` |\n\n`submitScoreFromSource` mirrors the confirmed score the same way `submitScore`\ndoes; use it when the score comes from a game event configured on the\nleaderboard's `ScoreSources` (a `TriggerSource`, same primitive used by\nTimedEvent/CoopEvent) rather than a value your client computed — pass the\n`sourceType`/`outcome`/`params` that identify which trigger fired, and\n`rollMultiplier` to scale it (defaults to 1). The backend only accepts\nclient-safe source types here — board/quest/store/marketplace/rank-reward\nsources are server-only and are rejected with `\"Source '{type}' cannot be\ncalled from client.\"`. It's also a **no-op that still returns `ok: true`**\n(`NewScore: 0, CycleVersion: 0`) when the leaderboard isn't `Sum`-aggregated,\nor no configured source matches — don't treat that as an error, and don't\nshow a \"+0\" toast without checking `NewScore > 0`.\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. Rewards ride along in\n`data.Resources` (single calls) and are already applied to cached balances;\nfor `claimCycleRewardsBatch`, `claimMilestonesBatch`, and\n`claimAllRewardsBatch`, each successful item's `Resources` is applied\nper-item as the batch result is processed.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values — that way every\nscreen stays consistent no matter which code path changed things.\n\n```ts\n// Definitions (cached after getDefinitions()):\nimport type { LeaderboardDefinitions } from \"@idosgames/core\";\nconst defs =\n client.data.config.getSection<LeaderboardDefinitions>(\"Leaderboard\");\n\n// Player progress, keyed by an internal `${titleID}_${leaderboardID}` cycle-doc id —\n// don't build the key yourself, look it up by matching LeaderboardID if you need to\n// scan, or just re-call getMyProgress/getProgressBatch and read the response directly.\nconst byCycle = client.data.user.state?.Leaderboard?.ProgressByCycle ?? {};\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `leaderboard:definitionsLoaded` → `LeaderboardDefinitions`\n- `leaderboard:loaded` → `GetLeaderboardResponse`\n- `leaderboard:loadedBatch` → `BatchItemResult<GetLeaderboardResponse>[]`\n- `leaderboard:myProgressLoaded` → `GetMyProgressResponse`\n- `leaderboard:myProgressLoadedBatch` → `BatchItemResult<GetMyProgressResponse>[]`\n- `leaderboard:scoreSubmitted` → `SubmitScoreResponse`\n- `leaderboard:scoresSubmittedBatch` → `BatchItemResult<SubmitScoreResponse>[]`\n- `leaderboard:cycleRewardClaimed` → `ClaimCycleRewardResponse`\n- `leaderboard:cycleRewardsClaimedBatch` → `BatchItemResult<ClaimCycleRewardResponse>[]`\n- `leaderboard:milestoneClaimed` → `ClaimLeaderboardMilestoneResponse`\n- `leaderboard:milestonesClaimedBatch` → `BatchItemResult<ClaimLeaderboardMilestoneResponse>[]`\n- `leaderboard:allRewardsClaimedBatch` → `ClaimAllRewardsBatchResponse`\n- `leaderboard:overviewLoadedBatch` → `GetOverviewBatchResponse`\n- `leaderboard:friendsLoaded` → `GetLeaderboardResponse`\n\nThe coarse `user:leaderboardUpdated` (and `user:anyUpdated`) also fire on any\nleaderboard cache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"leaderboard:cycleRewardClaimed\", (r) => {\n console.log(`${r.LeaderboardID}: rank ${r.Rank} reward claimed`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Golden path: submit a score, check progress, claim the rank reward\n\n```ts\nawait client.leaderboard.getDefinitions();\n\nconst sub = await client.leaderboard.submitScore(\"weekly_score\", 150);\nif (!sub.ok) return showError(sub.error);\nsub.data.NewScore; // score after aggregation (Sum/BestScore/BestTime/LastValue)\nsub.data.CycleVersion; // which cycle this landed in\n\nconst progress = await client.leaderboard.getMyProgress(\"weekly_score\");\nif (!progress.ok) return showError(progress.error);\nif (progress.data.HasUnclaimedReward) {\n const claim = await client.leaderboard.claimCycleReward(\"weekly_score\");\n if (!claim.ok) return showError(claim.error); // e.g. \"No unclaimed reward\"\n claim.data.Rank; // rank that earned the reward\n claim.data.Resources; // already applied to cached balances\n}\n```\n\n`submitScore`'s effect on `NewScore` depends on the leaderboard's\n`ScoreAggregation`: `Sum` adds to the running total, `BestScore`/`BestTime`\nonly replace if better, `LastValue` always overwrites. The SDK doesn't\nreplicate this math client-side — trust `NewScore` from the response. A\n`submitScore` call with `score <= 0` is rejected client-side with\n`reason: \"client\"` before any request is sent.\n\n⚠ The server takes a number from the game ONLY into a board with a\n`ClientScore` entry in its `ScoreSources` (its `Limits` cap the number: max\nper submission, per day, cooldown…). A board without it refuses every\n`submitScore` — add the source when you add a board the game submits to. See\n[references/data-model.md](references/data-model.md#score-sources-scoresources--submitscorefromsource).\n\n### Show a top list with the player's own progress (overview batch)\n\n```ts\nconst res = await client.leaderboard.getOverviewBatch([\n \"weekly_score\",\n \"season_pvp\",\n]);\nif (!res.ok) return showError(res.error);\n\nfor (const entry of res.data) {\n if (entry.Leaderboard?.Success) {\n entry.Leaderboard.Data?.TopUsers; // ranked entries for this board\n }\n if (entry.Progress?.Success) {\n entry.Progress.Data?.CurrentScore;\n entry.Progress.Data?.LastKnownRank;\n entry.Progress.Data?.NextMilestone; // next unreached MilestoneDefinition\n }\n}\n```\n\nUse `getOverviewBatch` for a multi-board hub screen instead of pairing\n`getLeaderboardsBatch` + `getProgressBatch` yourself — it's one round trip and\neach entry already pairs the two results by `LeaderboardID`.\n\n### Claim everything at once across several leaderboards\n\n```ts\nconst res = await client.leaderboard.claimAllRewardsBatch([\n \"weekly_score\",\n \"season_pvp\",\n]);\nif (!res.ok) return showError(res.error);\n\nfor (const entry of res.data) {\n if (entry.CycleReward?.Success) applyCycleReward(entry.LeaderboardID);\n for (const m of entry.Milestones ?? []) {\n if (m.Success) applyMilestone(entry.LeaderboardID, m.Data?.MilestoneID);\n else showItemError(entry.LeaderboardID, m.Error);\n }\n}\n```\n\nThis is the one-tap \"claim all\" button: it claims each leaderboard's rank\nreward (if any) and every reachable milestone, per-leaderboard. A leaderboard\nwith nothing to claim simply reports `CycleReward: null`/absent and an empty\n`Milestones` list — not an error.\n\n### Edge case: milestones during a grace window after cycle end\n\n```ts\nconst progress = await client.leaderboard.getMyProgress(\"weekly_score\");\nif (progress.ok) {\n for (const grace of progress.data.ClaimableGraceCycles ?? []) {\n // A past cycle/instance still inside its MilestoneClaimGraceHours window.\n for (const milestoneID of grace.ClaimableMilestoneIDs ?? []) {\n const claim = await client.leaderboard.claimMilestone(\n \"weekly_score\",\n milestoneID,\n );\n if (!claim.ok) showError(claim.error); // e.g. window expired mid-request\n }\n }\n}\n```\n\nWhen a leaderboard's `MilestoneClaimMode` is `AfterEventEnd` (or\n`FeaturedAfterEnd` for a featured milestone), milestones reached during a\ncycle/instance can't be claimed until it closes, and only within\n`MilestoneClaimGraceHours` afterward. `GetMyProgressResponse\n.ClaimableGraceCycles` lists any such still-open past cycles/instances (see\n`LeaderboardGraceCycle` in the reference) — check it in addition to the\ncurrent cycle's own milestones, since the current cycle's `NextMilestone` only\ndescribes the next _unreached_ one, not ones already earned but unclaimed\nunder `AfterEventEnd`/`FeaturedAfterEnd`.\n\n### Batch score submission from multiple game systems\n\n```ts\nconst res = await client.leaderboard.submitScoresBatch([\n { LeaderboardID: \"weekly_score\", Score: 150 },\n { LeaderboardID: \"season_pvp\", Score: 20 },\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (item.Success) applyOk(item.Id, item.Data?.NewScore);\n else showItemError(item.Id, item.Error); // e.g. this board is disabled\n}\n```\n\nBatch results are **partial-aware**: the outer `res.ok` tells you the call\nran; each element's `Success`/`Error` tells you whether that submission\napplied. Use this when one game action (e.g. finishing a match) should post a\nscore to several leaderboards at once instead of firing N separate\n`submitScore` calls.\n\n## Gotchas\n\n- **Batch atomicity is conditional, not blanket.** Unlike some other modules'\n batch actions, Leaderboard batches are only a single all-or-nothing\n transaction for the subset of targeted leaderboards that are **Cyclic and\n not bracketed**. Any leaderboard using `BracketSize > 0` or a\n `Scheduled`/`Chained` schedule is processed as an independent per-item\n operation inside the same batch call — each such item can succeed or fail\n on its own, and a failure there does **not** roll back the atomic cyclic\n group (nor vice versa). Don't assume \"the whole batch failed\" or \"the whole\n batch is one transaction\" for mixed leaderboard sets.\n- **Batch input is deduped, clamped, and silently overflowed.** All\n `*Batch`/array-of-IDs methods (and `claimMilestonesBatch`'s pairs,\n `submitScoresBatch`'s score refs) trim whitespace, drop empty entries,\n dedupe (first occurrence wins), and cap at **50 items per call** —\n anything past 50 is silently dropped and never appears in the results\n array. The SDK does not pre-validate or chunk this for you; if you can\n exceed 50 in one screen action, split into multiple calls yourself.\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations — resubmitting the same score\n twice (e.g. a double-tapped \"Submit\" button) posts it twice under `Sum`\n aggregation. Disable the control while a call is in flight. (Firing the same\n endpoint again within the throttle window, default 500 ms for this module,\n is rejected with `reason: \"throttled\"` rather than duplicated, but don't\n rely on that for correctness.)\n- **`NewScore` reflects aggregation, not addition.** Don't assume\n `NewScore === previous + submitted`; only `Sum` behaves that way.\n `BestScore`/`BestTime` may leave `NewScore` unchanged if the submission\n didn't beat the record. Always read `NewScore` from the response rather than\n computing it locally.\n- **Cache resets on a new cycle.** When a submitted score's `CycleVersion` is\n newer than the cached one, the SDK zeroes `ScoreEarnedThisCycle` and\n `ClaimedMilestoneIDs` and clears `BracketID` for that leaderboard — a stale\n UI holding old milestone-claimed state from the prior cycle will look wrong\n until you re-render from the cache after the bump.\n `UnclaimedRewardCycleVersion` is a separate flag driven by\n `HasUnclaimedReward` from `getMyProgress`, not by score submission.\n `claimCycleReward` only clears it, it doesn't reset the rest of the cycle.\n- **\"No unclaimed reward\" / \"Reward already claimed\" are normal outcomes, not\n bugs.** `claimCycleReward` and `claimMilestone` reject with\n `reason: \"server\"` if there's nothing to claim — always check\n `HasUnclaimedReward` (or `NextMilestone` / `HasUnclaimedMilestone`) from\n `getMyProgress` before showing a claim button.\n- **Friends view is a separate read, not a filter you apply.** Use\n `getFriendsLeaderboard`, not `getLeaderboard` — the backend does the\n friends-list filtering server-side; there's no client-side way to derive it\n from the global top list (a friend far outside `TopUsersLimit` wouldn't be\n in that list at all).\n- **Score submission and reward claiming both need auth; reads of\n Definitions/top-list/friends do too.** Every method in this service — reads\n included — requires `client.auth.context`; there's no anonymous leaderboard\n browsing in this SDK.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field, schedule modes (Cyclic vs Scheduled vs Chained instance\nidentity), score aggregation modes, rank-reward ranges, milestone claim\nmodes and grace windows, brackets, and the exact batch atomicity/dedup rules.\n",
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
8
- "content": "# Leaderboard data model — reference\n\nFull shape of the config (Definitions) and player state, the cycle/schedule\nmechanics, score aggregation, rank/milestone rewards, brackets, and batch\nsemantics. All of these are **strictly typed in the SDK** —\n`LeaderboardDefinitions` and every nested block (`LeaderboardDefinition`,\n`LeaderboardRankReward`, `LeaderboardChainedEvent`, the shared `ScheduleSpec`/\n`SegmentGate`/`MilestoneDefinition`/`TriggerSource`) are exported from\n`@idosgames/core`, so `getDefinitions()` and\n`getSection<LeaderboardDefinitions>(\"Leaderboard\")` give you concrete types,\nnot `unknown`. The schemas keep `.passthrough()`, so a field the backend adds\nlater still round-trips. Field names are PascalCase (straight from the\nbackend JSON).\n\n## Contents\n\n- [Player state](#player-state) — what's cached from `getMyProgress()` / batch reads\n- [Config: LeaderboardDefinitions](#config-leaderboarddefinitions) — what `getDefinitions()` returns\n- [LeaderboardDefinition](#leaderboarddefinition)\n- [Score aggregation](#score-aggregation)\n- [Schedule modes: Cyclic vs Scheduled vs Chained](#schedule-modes-cyclic-vs-scheduled-vs-chained)\n- [Rank rewards](#rank-rewards)\n- [Milestones & claim modes](#milestones--claim-modes)\n- [Brackets](#brackets)\n- [Score sources (ScoreSources / submitScoreFromSource)](#score-sources-scoresources--submitscorefromsource)\n- [Responses](#responses)\n- [Batch semantics](#batch-semantics)\n\n---\n\n## Player state\n\nProgress is cached per cycle/instance doc at\n`client.data.user.state?.Leaderboard?.ProgressByCycle[cycleDocID]`. For a\n`Cyclic` leaderboard, `cycleDocID` is `` `${titleID}_${leaderboardID}` ``\n(built by the service — never construct it yourself). On the backend, a\n`Scheduled`/`Chained` leaderboard uses a per-instance doc ID\n(`{base}:{instanceKey}`) instead; the SDK's client-facing methods operate on\nthe _current_ instance implicitly, so you normally don't need to know that\nsuffix — read progress back via the response's `LeaderboardID` if you need to\nscan the map.\n\n```ts\ninterface UserLeaderboardProgress {\n CurrentScore?: number; // this player's current score in the active cycle\n ScoreEarnedThisCycle?: number; // sum of submissions since the cycle started\n ClaimedMilestoneIDs?: string[]; // milestone ids already claimed this cycle\n ScoreCycleVersion?: number; // cycle version this progress belongs to\n LastScoreSubmitUtc?: string; // ISO timestamp of the last submitScore\n UnclaimedRewardCycleVersion?: number; // >0 = a rank reward is waiting; 0 = none/claimed\n LastClaimedCycleVersion?: number;\n LastKnownRank?: number; // rank as of the last getMyProgress/getLeaderboard read\n BracketID?: string | null; // which bracket this player is in, if BracketSize is set\n}\n\ninterface UserLeaderboardsState {\n ProgressByCycle?: Record<string, UserLeaderboardProgress>;\n}\n```\n\nCache-write rules the SDK applies for you (`packages/core/src/cache/UserData.ts`,\nports of the backend's `PatchLeaderboard*` progress mutations):\n\n- **`submitScore` / `submitScoreFromSource`** → `patchLeaderboardScoreSubmitted`:\n if the response's `CycleVersion` is newer than the cached\n `ScoreCycleVersion`, the cache resets `ScoreEarnedThisCycle` to 0, clears\n `ClaimedMilestoneIDs`, and clears `BracketID` (new cycle, clean slate)\n _before_ applying the new submission. `CurrentScore` is then always set to\n the response's `NewScore`; `ScoreEarnedThisCycle` accumulates the submitted\n amount (regardless of aggregation mode — it tracks _submissions_, not the\n aggregated score); `ScoreCycleVersion` and `LastScoreSubmitUtc` are updated\n unconditionally. Note `submitScoreFromSource`'s benign no-op response\n (`NewScore: 0, CycleVersion: 0` when no source matched, or the leaderboard\n isn't `Sum`-aggregated) still runs through this patch — it sets\n `CurrentScore` to 0 and touches `LastScoreSubmitUtc`, but with\n `CycleVersion: 0` it will not trigger the newer-cycle reset branch on a\n leaderboard whose cached version is already `>= 0`.\n- **`getMyProgress` / `getProgressBatch` / `getOverviewBatch`** →\n `patchLeaderboardMyProgress`: overwrites `CurrentScore`, `LastKnownRank`,\n `ScoreEarnedThisCycle` from the response, and sets\n `UnclaimedRewardCycleVersion` to `max(1, previous)` if `HasUnclaimedReward`\n is true, else resets it to 0.\n- **`claimCycleReward` / `claimCycleRewardsBatch` / `claimAllRewardsBatch`** →\n `patchLeaderboardCycleRewardClaimed`: zeroes `UnclaimedRewardCycleVersion`.\n Requires an existing progress entry (a no-op if the cache never saw this\n leaderboard).\n- **`claimMilestone` / `claimMilestonesBatch` / `claimAllRewardsBatch`** →\n `patchLeaderboardMilestoneClaimed`: appends the milestone id to\n `ClaimedMilestoneIDs` (dedup'd).\n\n---\n\n## Config: LeaderboardDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<LeaderboardDefinitions>(\"Leaderboard\")`.\n\n```ts\ninterface LeaderboardDefinitions {\n Definitions?: Record<string, LeaderboardDefinition>; // key = LeaderboardID\n}\n```\n\n---\n\n## LeaderboardDefinition\n\nOne title leaderboard: identity, schedule, gate, brackets, rank rewards,\nmilestones, and what feeds it a score. Source: `LeaderboardDefinition.cs`\n(`IDosGamesSDK/API/Client/v2/Leaderboard/Models/LeaderboardDefinitions.cs`).\n\n```ts\ninterface LeaderboardDefinition {\n LeaderboardID: string;\n DisplayName?: string | null;\n ScoreDisplayName?: string | null; // label for the score column, e.g. \"Kills\", \"Time\"\n AssetPaths?: Record<string, string> | null;\n IsEnabled?: boolean | null; // default true; false rejects reads/writes\n\n ScoreAggregation?: string | null; // Sum | BestScore | BestTime | LastValue; default Sum\n Schedule?: ScheduleSpec | null; // schedule/instance container (shared block); default Mode=Cyclic\n ChainEvents?: LeaderboardChainedEvent[] | null; // phases, when Schedule.Mode === \"Chained\"\n Gate?: SegmentGate | null; // who can see/participate (shared block)\n\n TopUsersLimit?: number | null; // max entries stored/returned in the top list; default 100\n MinScoreToEnterTop?: number | null; // score floor to appear in the top list; default 1\n BracketSize?: number | null; // players per bracket; default 0 = one global board\n\n LinkedSeasonChainID?: string | null; // ties rank/milestone rewards to a Season chain's tiers\n\n RankRewards?: LeaderboardRankReward[] | null;\n Milestones?: Record<string, MilestoneDefinition> | null; // key = MilestoneID\n\n MilestoneClaimMode?: string | null; // Instant | AfterEventEnd | FeaturedAfterEnd; default Instant\n MilestoneClaimGraceHours?: number | null; // claim window after cycle/instance end; default 0\n\n ScoreSources?: TriggerSource[] | null; // what `submitScoreFromSource` matches against\n}\n```\n\nA leaderboard with `IsEnabled: false` rejects reads/writes with\n`\"Leaderboard '{id}' is disabled\"` — check it before showing the screen.\n`Gate` is the same `SegmentGate` used across the SDK (segments, premium tier,\nlevel range, country, experiment variant — see `.claude/skills/premium-system/SKILL.md`\nfor how `MinPremiumTier` is evaluated) — all conditions are AND-ed,\nempty/absent gate = open to everyone. A player failing the gate gets\n`\"Leaderboard '{id}' is not available for you\"` from both `getLeaderboard`\nand `submitScore`.\n\n---\n\n## Score aggregation\n\n`ScoreAggregation` controls what `submitScore`'s `NewScore` becomes relative\nto the player's existing score in the current cycle\n(`LeaderboardV2.AggregateScore`):\n\n| Mode | Behavior |\n| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `Sum` | Adds the submitted value to the running total. |\n| `BestScore` | Keeps the higher of the two (`Math.Max(current, incoming)`). |\n| `BestTime` | Keeps the lower of the two (`Math.Min`), except the very first submission (`current == 0`) always wins outright — a `0` current score never beats a real time. |\n| `LastValue` | Always overwrites with the latest submission. |\n\nThis math is backend-owned — the SDK never recomputes it; always read\n`NewScore` from `SubmitScoreResponse` rather than predicting it client-side.\n`ScoreEarnedThisCycle` in the cache tracks the sum of raw submissions\nregardless of aggregation mode (useful for milestone progress even under\n`BestScore`/`LastValue`, where `CurrentScore` itself may not move on every\nsubmit). Top-list sort order (`LeaderboardV2.SortTopUsers`) mirrors this:\n`BestTime` sorts ascending by score (lower = better), everything else sorts\ndescending; ties break by whoever reached that score first\n(`LastScoreUpdateUtc` ascending, both for `BestTime` and the other modes).\n\n`ScoreSources`-driven accrual (`submitScoreFromSource`, and all server-side\nboard/marketplace score grants) is accumulative by nature and is therefore\n**only wired up for `Sum`-aggregated leaderboards** — see\n[Score sources](#score-sources-scoresources--submitscorefromsource).\n\n---\n\n## Schedule modes: Cyclic vs Scheduled vs Chained\n\n`Schedule` is the shared `ScheduleSpec` block (same primitive used by\nTimedEvent, TimedBoost, CoopEvent, Season, DealOffer). For leaderboards only\nthree `Mode` values matter in practice:\n\n```ts\ninterface ScheduleSpec {\n Mode?: string; // Cyclic | Scheduled | Chained | AlwaysOn | Triggered | Relative\n IsActive?: boolean;\n Scheduled?: ScheduledWindow; // fixed StartUtc/EndUtc window (+ ClaimGraceHours, AllowEarningAfterEnd)\n Chain?: ScheduleChain; // recurring phase-chain timing (AnchorUtc, PauseBetweenPhasesSec, PauseBetweenCyclesSec)\n Cyclic?: CyclicSchedule; // calendar-aligned reset (Reset: Daily/Weekly/.../Never, + FixedInterval fields)\n ActivationTriggers?: TriggerSource[]; // unused by Leaderboard (Triggered mode not resolved here)\n Relative?: RelativeWindow; // unused by Leaderboard\n}\n```\n\nEach mode gives the leaderboard a different **instance identity** — this is\nthe single most important thing to get right when building UI around\n`CycleIndex`/`ChainedEventID`/grace windows:\n\n- **`Cyclic`** (default) — one leaderboard, an endless series of cycles keyed\n by a calendar-aligned `CyclicSchedule.Reset` (`Daily`/`Weekly`/`Monthly`/...\n /`Never`/`FixedInterval`). The doc ID is fixed\n (`{titleID}_{leaderboardID}`); what moves is `CycleVersion`, incremented\n each reset. `submitScore`, `getMyProgress`, `claimCycleReward`, and\n `claimMilestone` all operate on the _current_ cycle implicitly — you never\n pass a cycle selector for normal play. Past cycles are only reachable via\n `ClaimableGraceCycles` (see [Milestones](#milestones--claim-modes)) and via\n `LeaderboardRequest.CycleKey` on `claimMilestone` — the SDK's\n `claimMilestone(leaderboardID, milestoneID)` does not currently expose a\n `CycleKey` parameter, so client code can only claim grace-window milestones\n the SDK's typed method surfaces for the _current_ call shape; treat this as\n a backend capability documented for completeness rather than something the\n bundled recipes exercise beyond what's already reachable from\n `ClaimableGraceCycles`.\n- **`Scheduled`** — a single fixed window (`Schedule.Scheduled.StartUtc`/\n `EndUtc`). There is exactly one instance, ever, for that config. Before\n `StartUtc` or after `EndUtc + ClaimGraceHours` there is no active instance\n (`getLeaderboard`/`getOverviewBatch` return an empty top list;\n `submitScore` fails with `\"has no active instance right now\"`).\n- **`Chained`** — a repeating sequence of named phases (`ChainEvents`, each\n with `Order`/`DurationSec`/`ClaimGraceHours`), running back-to-back with\n `Schedule.Chain.PauseBetweenPhasesSec` between phases and\n `PauseBetweenCyclesSec` between full loops of the chain. Each\n `(CycleIndex, ChainedEventID)` pair is an **independent leaderboard\n instance** — its own top list, its own `RankRewards`/`Milestones` slate\n (shared config, independent progress), its own bracket set, its own token\n bucket. `LeaderboardRequest` (and hence some backend actions) can address a\n specific past phase via `CycleIndex`/`ChainedEventID`, but the SDK's\n everyday methods (`submitScore`, `getMyProgress`, `claimCycleReward`,\n `claimMilestone`) implicitly target the _current_ active phase; you\n encounter `ChainedEventID`/`CycleIndex` values mainly when reading them\n back off `LeaderboardGraceCycle` for a phase that just ended.\n\nFor all three modes, `CycleVersion` on `SubmitScoreResponse`/\n`GetLeaderboardResponse` is what the SDK cache compares against the cached\n`ScoreCycleVersion` to detect \"this is a new cycle, reset local per-cycle\nstate\" — for `Scheduled`/`Chained` instances this version is always `1`\n(instance identity lives in the doc ID, not the version number).\n\n`ScheduledWindow.ClaimGraceHours` / each `LeaderboardChainedEvent\n.ClaimGraceHours` set how long after that instance ends its rank reward and\nmilestones stay claimable — mirrors the `MilestoneClaimGraceHours` concept\ndescribed in [Milestones](#milestones--claim-modes) but is a _per-instance_\nwindow for Scheduled/Chained rather than the leaderboard-wide\n`MilestoneClaimGraceHours` used by Cyclic.\n\n---\n\n## Rank rewards\n\n```ts\ninterface LeaderboardRankReward {\n Rank?: string; // rank range: \"1\", \"2-5\", \"6-10\", \"11-50\", ...\n Rewards?: ResourceGrant; // grant-only — no cost side\n SeasonTierRewards?: SeasonTierRewardSet; // optional season-tier scaling overlay\n}\n```\n\n`Rank` is a string range matched against the player's final rank when the\ncycle/instance closes (`LeaderboardV2.IsRankInRange`: an exact integer like\n`\"1\"`, or an inclusive `\"start-end\"` range). `claimCycleReward` looks up\nwhich range the player's `Rank` (from `ClaimCycleRewardResponse.Rank`) falls\ninto and pays out that entry's `Rewards`. If `LinkedSeasonChainID` is set,\n`SeasonTierRewards` can scale or replace the base reward based on the\nplayer's tier in that season chain, resolved from a **snapshot**\n(`LeaderboardUserEntry.SeasonTierAtCycleEnd`) taken when the cycle/instance\nwas archived — not the player's current tier — so a season reset between\ncycle-end and claim doesn't change what a past cycle pays out. (`Bundles`\nreplace on match, `Multipliers` scale the base `Rewards.Amounts` — same\noverlay mechanic as `MilestoneDefinition.SeasonTierRewards`.)\n\nA player only has something to claim when `HasUnclaimedReward` (from\n`getMyProgress`) is true, mirrored into the cache as\n`UnclaimedRewardCycleVersion > 0`. If the player wasn't in the final top list,\nor their rank has no matching `RankRewards` entry, or the resolved grant is\nempty after premium filtering, the backend auto-marks the cycle as claimed\nwithout granting anything (`\"No unclaimed reward\"` on a later claim attempt,\nor a normal `claimCycleReward` success with an empty `Resources`) — don't\ntreat either as an error state to alarm the player over.\n\n---\n\n## Milestones & claim modes\n\n`Milestones` uses the SDK-wide `MilestoneDefinition` primitive (shared with\nQuest, TimedEvent, CoopEvent, DealOffer):\n\n```ts\ninterface MilestoneDefinition {\n MilestoneID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n RequiredProgress?: number; // threshold checked against accumulated progress\n Rewards?: ResourceGrant;\n BonusRewards?: ResourceGrant; // paid during a bonus window, if applicable\n SeasonTierRewards?: SeasonTierRewardSet;\n SortOrder?: number;\n IsFeatured?: boolean;\n}\n```\n\nFor leaderboards, progress toward a milestone is tracked by a dedicated\n**leaderboard event token** (`EventTokenType.Leaderboard`, address\n`\"{leaderboardID}:{cycleKey}\"` for Cyclic or `\"{leaderboardID}:{instanceKey}\"`\nfor Scheduled/Chained) — its cumulative `Balance.TotalEarned` is the\nscore-earning activity in the current cycle/instance\n(`ScoreEarnedThisCycle` / the response's `MilestoneTokensEarned`), separate\nfrom the ranked `CurrentScore`. A `BestScore` leaderboard where you resubmit\na lower score still accrues milestone progress from the submission, because\nthe token accrues every submitted amount regardless of aggregation.\n\n`MilestoneClaimMode` controls _when_ a reached milestone can be claimed\n(`IDosGames.MilestoneClaimMode`, shared enum — **note the exact member\nnames**, they are not `\"AfterCycleEnd\"`):\n\n- **`Instant`** — claimable as soon as it's reached, any time during the\n cycle/instance, via `claimMilestone` / `claimMilestonesBatch`.\n- **`AfterEventEnd`** — every milestone must wait until the cycle/instance\n closes, and then only within `MilestoneClaimGraceHours` (Cyclic) or the\n instance's own `ClaimGraceHours` (Scheduled/Chained) of the close. Claiming\n early fails with `\"Milestone can only be claimed after cycle ends\"` (Cyclic)\n or `\"...after instance ends\"` (Scheduled/Chained).\n- **`FeaturedAfterEnd`** — hybrid: milestones with `IsFeatured: false` claim\n `Instant`-style any time; milestones with `IsFeatured: true` behave like\n `AfterEventEnd` (`\"Featured milestone can only be claimed after cycle\nends\"`).\n\n`GetMyProgressResponse.ClaimableGraceCycles` lists past cycles/instances\nstill inside the claim window:\n\n```ts\ninterface LeaderboardGraceCycle {\n CycleKey?: string;\n CycleEndUtc?: string;\n ClaimDeadlineUtc?: string; // CycleEndUtc + grace hours\n MilestoneTokensEarned?: number;\n ClaimableMilestoneIDs?: string[]; // milestones from that cycle/instance still claimable\n CycleIndex?: number; // Chained only\n ChainedEventID?: string; // Chained only\n}\n```\n\nOnce `ClaimDeadlineUtc` passes, that cycle/instance's milestones are no\nlonger claimable — check `ClaimableGraceCycles` (and its per-cycle deadline)\nrather than assuming a reached-but-unclaimed milestone stays claimable\nindefinitely. `MilestoneClaimGraceHours: 0` (the default) means Cyclic grace\nis disabled entirely — once a cycle rolls over, its milestones are gone for\ngood.\n\n---\n\n## Brackets\n\nWhen `BracketSize` is set (default `0` = disabled), the leaderboard splits\nplayers into fixed-size brackets rather than one global ranking (keeps ranks\nmeaningful at scale). A player is assigned to a bracket on their **first\nscore submission of the cycle**; resubmissions in the same cycle update their\nscore within that same bracket. A player's current bracket is `BracketID` on\nboth the cached progress and `GetLeaderboardResponse.BracketID`. `BracketID`\nresets to `null` whenever the cache detects a new cycle (see the score-submit\nreset rule above) — a new cycle reshuffles brackets. Bracket assignment\nretries up to 5 times server-side under contention (multiple players racing\nto fill the last slot of a bracket); in the rare case all retries are\nexhausted the player is left without a bracket for that cycle and sees an\nempty top list until their next submission.\n\n---\n\n## Score sources (ScoreSources / submitScoreFromSource)\n\n`ScoreSources: TriggerSource[]` lets a leaderboard accrue score automatically\nfrom game events, instead of relying solely on an explicit `submitScore`\ncall — the same unified `TriggerSource` primitive TimedEvent uses for token\nsources:\n\n```ts\ninterface TriggerSource {\n SourceType?: string; // e.g. \"DailyLogin\", \"ReferralInvite\", \"CustomAction\", board/quest/store/marketplace types\n BaseWeight?: number; // score granted per matching trigger\n ScaleWithRollMultiplier?: boolean; // multiply BaseWeight by the caller's rollMultiplier/volume\n TileTypeFilter?: string[];\n TileIndexFilter?: number[];\n ChanceOutcomeFilter?: string[];\n OutcomeFilter?: string[];\n Params?: Record<string, string>;\n Limits?: LimitSpec;\n}\n```\n\nTwo independent gates apply, both verified in `LeaderboardV2.cs`:\n\n1. **Aggregation gate** — `ScoreSources` are cumulative deltas\n (`BaseScore × multiplier`), so they're only ever matched/applied on\n `Sum`-aggregated leaderboards. On any other `ScoreAggregation`, both the\n server-side board/marketplace grant builders and\n `submitScoreFromSource` silently skip (no score change, no error) — for\n `BestScore`/`BestTime`/`LastValue` leaderboards, score must come from an\n explicit `submitScore` with the real value (a time, a high score), not\n accumulate off game events.\n2. **Client/server source gate** — `submitScoreFromSource` (the client-facing\n gateway) accepts ONLY `CustomAction` (with your own `Params`) and\n `DailyLogin` (a whitelist since 2026-09-28). Every other type is rejected\n with `\"Source '{type}' cannot be called from client.\"` — the server credits\n them itself when the action happens: board rolls, store purchases, quest\n claims, marketplace deals, new invited friends (`ReferralInvite`), currency\n spent/earned, real-money purchases, lootbox opens, crypto, rank rewards.\n3. **A board fed only by server sources refuses a client score** — a\n `submitScore`/`submitScoresBatch` into it fails with\n `\"Leaderboard '{id}' is scored by the server only.\"` (e.g. a weekly\n \"top inviters\" board). Show it; don't submit to it.\n\nIf no configured `ScoreSources` entry matches the given `sourceType`/\n`outcome`/`params` (or `BaseWeight <= 0`), the call is a **benign no-op**:\n`ok: true` with `{ NewScore: 0, CycleVersion: 0 }`, not an error — the\nbackend deliberately doesn't leak \"does this source exist\" information via\nan error. Don't render a `NewScore` toast without checking it's `> 0`.\n\n---\n\n## Responses\n\n```ts\ninterface LeaderboardUserEntry {\n UserID: string;\n Score?: number;\n Rank?: number;\n LastScoreUpdateUtc?: string;\n IpHash?: string;\n PublicProfile?: UserPublicDataModel; // display name/avatar for rendering the row\n SeasonTierAtCycleEnd?: number; // this entry's season tier when the cycle closed (archive snapshot only; 0 while cycle is active)\n}\n\ninterface GetLeaderboardResponse {\n LeaderboardID: string;\n CycleVersion?: number;\n TotalParticipants?: number;\n CycleEndUtc?: string;\n TopUsers?: LeaderboardUserEntry[]; // length capped at TopUsersLimit\n BracketID?: string; // set when BracketSize splits the board\n}\n\ninterface GetMyProgressResponse {\n LeaderboardID: string;\n CurrentScore?: number;\n LastKnownRank?: number;\n HasUnclaimedReward?: boolean;\n ScoreEarnedThisCycle?: number;\n MilestoneTokensEarned?: number;\n NextMilestone?: MilestoneDefinition; // next unreached milestone, or absent if none left\n HasUnclaimedMilestone?: boolean;\n ClaimableGraceCycles?: LeaderboardGraceCycle[];\n}\n\ninterface SubmitScoreResponse {\n LeaderboardID: string;\n NewScore: number;\n CycleVersion: number;\n}\n\ninterface ClaimCycleRewardResponse {\n ServerTimeUtc: string;\n LeaderboardID: string;\n Rank?: number;\n Resources?: ResourceOperation;\n}\n\ninterface ClaimLeaderboardMilestoneResponse {\n ServerTimeUtc: string;\n LeaderboardID: string;\n MilestoneID: string;\n Resources?: ResourceOperation;\n}\n\ninterface LeaderboardOverview {\n LeaderboardID: string;\n Leaderboard?: BatchItemResult<GetLeaderboardResponse> | null;\n Progress?: BatchItemResult<GetMyProgressResponse> | null;\n}\n\ninterface ClaimAllRewardsResult {\n LeaderboardID: string;\n CycleReward?: BatchItemResult<ClaimCycleRewardResponse> | null;\n Milestones?: BatchItemResult<ClaimLeaderboardMilestoneResponse>[] | null;\n}\n```\n\n---\n\n## Batch semantics\n\nAll batch/array-of-IDs inputs (`LeaderboardIDs`, `Milestones` pairs, `Scores`\npairs) go through the same normalization on the backend\n(`BatchSupport.NormalizeIDs` / equivalent inline dedup in\n`SubmitScoresBatch`/`ClaimMilestonesBatch`): entries are trimmed, empty/\ninvalid ones dropped, duplicates removed (first occurrence wins, by ID for\nsingle-value batches or by the natural composite key — `LeaderboardID` for\nscores, `` `${LeaderboardID}:${MilestoneID}` `` for milestone pairs), then the\nlist is **clamped to 50 items**. Anything past the 50th is **silently\ndropped** — it never appears in the result array at all, success or failure.\nThe SDK does not pre-validate, warn, or chunk this for you.\n\n**Atomicity is per-subset, not per-call**, and this is the biggest surprise\nrelative to other v2 modules' batch actions:\n\n- `submitScoresBatch`: leaderboards that are `Cyclic` **and** not bracketed\n (`BracketSize == 0`) are all folded into a **single Mongo transaction** —\n either every one of those items' score+top-list update commits, or none\n do. Leaderboards that are bracketed, or `Scheduled`/`Chained`, are instead\n processed **one at a time** by internally re-calling the same code path as\n the single `submitScore` action — each such item is its own independent\n operation, can fail on its own, and does not affect the atomic group's\n outcome (or vice versa).\n- `claimCycleRewardsBatch` / `claimMilestonesBatch` / `claimAllRewardsBatch`:\n same split. `Cyclic` (non-bracketed or bracketed — bracket archives are\n just a different lookup, still part of the cyclic path) targets are merged\n into **one atomic claim** (`ApplyResourceOperationAtomicAsync` with all\n their patches/filters ANDed and their `Resources` grants merged — see\n `ApplyClaimBatchAsync`/`BatchSupport.ApplyMergedClaimAsync`). `Scheduled`/\n `Chained` targets are each claimed independently (one atomic operation\n _per instance_, since instances have no shared version to gate a joint\n transaction on), and for `claimAllRewardsBatch`/`claimCycleRewardsBatch` a\n non-cyclic leaderboard may internally claim **several ended-but-in-grace\n instances at once**, merging their resources into one aggregated\n `BatchItemResult` entry.\n- Where a merged claim did pay out resources, the combined `ResourceOperation`\n is attached to only the **first** successful item that had a non-empty\n grant in each atomic group (`resourcesAttached` flag) — don't expect every\n successful item in a multi-leaderboard claim to carry its own slice of\n `Resources`; read the aggregate off whichever item has it and trust the\n SDK's applied-balance side effects for the rest.\n- Invalid items (unknown leaderboard/milestone, \"already claimed\", \"no\n unclaimed reward\", timing violations) are filtered out **before** the\n atomic transaction is built, so one stale/bad entry in a batch never blocks\n the others from committing.\n\nIdempotency for single calls follows the platform-wide `RelatedEntityID`\npattern (`ResourceService.ResolveRelatedEntityID`): cycle\nclaims key off `lb_cycle_{leaderboardID}_v{cycleVersion}` (or\n`lb_inst_{instanceDocID}` for Scheduled/Chained), milestone claims key off\n`lb_milestone_{tokenEntityID}_{milestoneID}`. Batch claims build one\ncombined, deterministic reason from the sorted keys of everything actually\nincluded in that transaction (`BatchSupport.BuildBatchReason`) — retries with\nthe same effective item set are idempotent; changing the item set changes the\nreason.\n"
8
+ "content": "# Leaderboard data model — reference\n\nFull shape of the config (Definitions) and player state, the cycle/schedule\nmechanics, score aggregation, rank/milestone rewards, brackets, and batch\nsemantics. All of these are **strictly typed in the SDK** —\n`LeaderboardDefinitions` and every nested block (`LeaderboardDefinition`,\n`LeaderboardRankReward`, `LeaderboardChainedEvent`, the shared `ScheduleSpec`/\n`SegmentGate`/`MilestoneDefinition`/`TriggerSource`) are exported from\n`@idosgames/core`, so `getDefinitions()` and\n`getSection<LeaderboardDefinitions>(\"Leaderboard\")` give you concrete types,\nnot `unknown`. The schemas keep `.passthrough()`, so a field the backend adds\nlater still round-trips. Field names are PascalCase (straight from the\nbackend JSON).\n\n## Contents\n\n- [Player state](#player-state) — what's cached from `getMyProgress()` / batch reads\n- [Config: LeaderboardDefinitions](#config-leaderboarddefinitions) — what `getDefinitions()` returns\n- [LeaderboardDefinition](#leaderboarddefinition)\n- [Score aggregation](#score-aggregation)\n- [Schedule modes: Cyclic vs Scheduled vs Chained](#schedule-modes-cyclic-vs-scheduled-vs-chained)\n- [Rank rewards](#rank-rewards)\n- [Milestones & claim modes](#milestones--claim-modes)\n- [Brackets](#brackets)\n- [Score sources (ScoreSources / submitScoreFromSource)](#score-sources-scoresources--submitscorefromsource)\n- [Responses](#responses)\n- [Batch semantics](#batch-semantics)\n\n---\n\n## Player state\n\nProgress is cached per cycle/instance doc at\n`client.data.user.state?.Leaderboard?.ProgressByCycle[cycleDocID]`. For a\n`Cyclic` leaderboard, `cycleDocID` is `` `${titleID}_${leaderboardID}` ``\n(built by the service — never construct it yourself). On the backend, a\n`Scheduled`/`Chained` leaderboard uses a per-instance doc ID\n(`{base}:{instanceKey}`) instead; the SDK's client-facing methods operate on\nthe _current_ instance implicitly, so you normally don't need to know that\nsuffix — read progress back via the response's `LeaderboardID` if you need to\nscan the map.\n\n```ts\ninterface UserLeaderboardProgress {\n CurrentScore?: number; // this player's current score in the active cycle\n ScoreEarnedThisCycle?: number; // sum of submissions since the cycle started\n ClaimedMilestoneIDs?: string[]; // milestone ids already claimed this cycle\n ScoreCycleVersion?: number; // cycle version this progress belongs to\n LastScoreSubmitUtc?: string; // ISO timestamp of the last submitScore\n UnclaimedRewardCycleVersion?: number; // >0 = a rank reward is waiting; 0 = none/claimed\n LastClaimedCycleVersion?: number;\n LastKnownRank?: number; // rank as of the last getMyProgress/getLeaderboard read\n BracketID?: string | null; // which bracket this player is in, if BracketSize is set\n}\n\ninterface UserLeaderboardsState {\n ProgressByCycle?: Record<string, UserLeaderboardProgress>;\n}\n```\n\nCache-write rules the SDK applies for you (`packages/core/src/cache/UserData.ts`,\nports of the backend's `PatchLeaderboard*` progress mutations):\n\n- **`submitScore` / `submitScoreFromSource`** → `patchLeaderboardScoreSubmitted`:\n if the response's `CycleVersion` is newer than the cached\n `ScoreCycleVersion`, the cache resets `ScoreEarnedThisCycle` to 0, clears\n `ClaimedMilestoneIDs`, and clears `BracketID` (new cycle, clean slate)\n _before_ applying the new submission. `CurrentScore` is then always set to\n the response's `NewScore`; `ScoreEarnedThisCycle` accumulates the submitted\n amount (regardless of aggregation mode — it tracks _submissions_, not the\n aggregated score); `ScoreCycleVersion` and `LastScoreSubmitUtc` are updated\n unconditionally. Note `submitScoreFromSource`'s benign no-op response\n (`NewScore: 0, CycleVersion: 0` when no source matched, or the leaderboard\n isn't `Sum`-aggregated) still runs through this patch — it sets\n `CurrentScore` to 0 and touches `LastScoreSubmitUtc`, but with\n `CycleVersion: 0` it will not trigger the newer-cycle reset branch on a\n leaderboard whose cached version is already `>= 0`.\n- **`getMyProgress` / `getProgressBatch` / `getOverviewBatch`** →\n `patchLeaderboardMyProgress`: overwrites `CurrentScore`, `LastKnownRank`,\n `ScoreEarnedThisCycle` from the response, and sets\n `UnclaimedRewardCycleVersion` to `max(1, previous)` if `HasUnclaimedReward`\n is true, else resets it to 0.\n- **`claimCycleReward` / `claimCycleRewardsBatch` / `claimAllRewardsBatch`** →\n `patchLeaderboardCycleRewardClaimed`: zeroes `UnclaimedRewardCycleVersion`.\n Requires an existing progress entry (a no-op if the cache never saw this\n leaderboard).\n- **`claimMilestone` / `claimMilestonesBatch` / `claimAllRewardsBatch`** →\n `patchLeaderboardMilestoneClaimed`: appends the milestone id to\n `ClaimedMilestoneIDs` (dedup'd).\n\n---\n\n## Config: LeaderboardDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<LeaderboardDefinitions>(\"Leaderboard\")`.\n\n```ts\ninterface LeaderboardDefinitions {\n Definitions?: Record<string, LeaderboardDefinition>; // key = LeaderboardID\n}\n```\n\n---\n\n## LeaderboardDefinition\n\nOne title leaderboard: identity, schedule, gate, brackets, rank rewards,\nmilestones, and what feeds it a score. Source: `LeaderboardDefinition.cs`\n(`IDosGamesSDK/API/Client/v2/Leaderboard/Models/LeaderboardDefinitions.cs`).\n\n```ts\ninterface LeaderboardDefinition {\n LeaderboardID: string;\n DisplayName?: string | null;\n ScoreDisplayName?: string | null; // label for the score column, e.g. \"Kills\", \"Time\"\n AssetPaths?: Record<string, string> | null;\n IsEnabled?: boolean | null; // default true; false rejects reads/writes\n\n ScoreAggregation?: string | null; // Sum | BestScore | BestTime | LastValue; default Sum\n Schedule?: ScheduleSpec | null; // schedule/instance container (shared block); default Mode=Cyclic\n ChainEvents?: LeaderboardChainedEvent[] | null; // phases, when Schedule.Mode === \"Chained\"\n Gate?: SegmentGate | null; // who can see/participate (shared block)\n\n TopUsersLimit?: number | null; // max entries stored/returned in the top list; default 100\n MinScoreToEnterTop?: number | null; // score floor to appear in the top list; default 1\n BracketSize?: number | null; // players per bracket; default 0 = one global board\n\n LinkedSeasonChainID?: string | null; // ties rank/milestone rewards to a Season chain's tiers\n\n RankRewards?: LeaderboardRankReward[] | null;\n Milestones?: Record<string, MilestoneDefinition> | null; // key = MilestoneID\n\n MilestoneClaimMode?: string | null; // Instant | AfterEventEnd | FeaturedAfterEnd; default Instant\n MilestoneClaimGraceHours?: number | null; // claim window after cycle/instance end; default 0\n\n ScoreSources?: TriggerSource[] | null; // what `submitScoreFromSource` matches against\n}\n```\n\nA leaderboard with `IsEnabled: false` rejects reads/writes with\n`\"Leaderboard '{id}' is disabled\"` — check it before showing the screen.\n`Gate` is the same `SegmentGate` used across the SDK (segments, premium tier,\nlevel range, country, experiment variant — see `.claude/skills/premium-system/SKILL.md`\nfor how `MinPremiumTier` is evaluated) — all conditions are AND-ed,\nempty/absent gate = open to everyone. A player failing the gate gets\n`\"Leaderboard '{id}' is not available for you\"` from both `getLeaderboard`\nand `submitScore`.\n\n---\n\n## Score aggregation\n\n`ScoreAggregation` controls what `submitScore`'s `NewScore` becomes relative\nto the player's existing score in the current cycle\n(`LeaderboardV2.AggregateScore`):\n\n| Mode | Behavior |\n| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `Sum` | Adds the submitted value to the running total. |\n| `BestScore` | Keeps the higher of the two (`Math.Max(current, incoming)`). |\n| `BestTime` | Keeps the lower of the two (`Math.Min`), except the very first submission (`current == 0`) always wins outright — a `0` current score never beats a real time. |\n| `LastValue` | Always overwrites with the latest submission. |\n\nThis math is backend-owned — the SDK never recomputes it; always read\n`NewScore` from `SubmitScoreResponse` rather than predicting it client-side.\n`ScoreEarnedThisCycle` in the cache tracks the sum of raw submissions\nregardless of aggregation mode (useful for milestone progress even under\n`BestScore`/`LastValue`, where `CurrentScore` itself may not move on every\nsubmit). Top-list sort order (`LeaderboardV2.SortTopUsers`) mirrors this:\n`BestTime` sorts ascending by score (lower = better), everything else sorts\ndescending; ties break by whoever reached that score first\n(`LastScoreUpdateUtc` ascending, both for `BestTime` and the other modes).\n\n`ScoreSources`-driven accrual (`submitScoreFromSource`, and all server-side\nboard/marketplace score grants) is accumulative by nature and is therefore\n**only wired up for `Sum`-aggregated leaderboards** — see\n[Score sources](#score-sources-scoresources--submitscorefromsource).\n\n---\n\n## Schedule modes: Cyclic vs Scheduled vs Chained\n\n`Schedule` is the shared `ScheduleSpec` block (same primitive used by\nTimedEvent, TimedBoost, CoopEvent, Season, DealOffer). For leaderboards only\nthree `Mode` values matter in practice:\n\n```ts\ninterface ScheduleSpec {\n Mode?: string; // Cyclic | Scheduled | Chained | AlwaysOn | Triggered | Relative\n IsActive?: boolean;\n Scheduled?: ScheduledWindow; // fixed StartUtc/EndUtc window (+ ClaimGraceHours, AllowEarningAfterEnd)\n Chain?: ScheduleChain; // recurring phase-chain timing (AnchorUtc, PauseBetweenPhasesSec, PauseBetweenCyclesSec)\n Cyclic?: CyclicSchedule; // calendar-aligned reset (Reset: Daily/Weekly/.../Never, + FixedInterval fields)\n ActivationTriggers?: TriggerSource[]; // unused by Leaderboard (Triggered mode not resolved here)\n Relative?: RelativeWindow; // unused by Leaderboard\n}\n```\n\nEach mode gives the leaderboard a different **instance identity** — this is\nthe single most important thing to get right when building UI around\n`CycleIndex`/`ChainedEventID`/grace windows:\n\n- **`Cyclic`** (default) — one leaderboard, an endless series of cycles keyed\n by a calendar-aligned `CyclicSchedule.Reset` (`Daily`/`Weekly`/`Monthly`/...\n /`Never`/`FixedInterval`). The doc ID is fixed\n (`{titleID}_{leaderboardID}`); what moves is `CycleVersion`, incremented\n each reset. `submitScore`, `getMyProgress`, `claimCycleReward`, and\n `claimMilestone` all operate on the _current_ cycle implicitly — you never\n pass a cycle selector for normal play. Past cycles are only reachable via\n `ClaimableGraceCycles` (see [Milestones](#milestones--claim-modes)) and via\n `LeaderboardRequest.CycleKey` on `claimMilestone` — the SDK's\n `claimMilestone(leaderboardID, milestoneID)` does not currently expose a\n `CycleKey` parameter, so client code can only claim grace-window milestones\n the SDK's typed method surfaces for the _current_ call shape; treat this as\n a backend capability documented for completeness rather than something the\n bundled recipes exercise beyond what's already reachable from\n `ClaimableGraceCycles`.\n- **`Scheduled`** — a single fixed window (`Schedule.Scheduled.StartUtc`/\n `EndUtc`). There is exactly one instance, ever, for that config. Before\n `StartUtc` or after `EndUtc + ClaimGraceHours` there is no active instance\n (`getLeaderboard`/`getOverviewBatch` return an empty top list;\n `submitScore` fails with `\"has no active instance right now\"`).\n- **`Chained`** — a repeating sequence of named phases (`ChainEvents`, each\n with `Order`/`DurationSec`/`ClaimGraceHours`), running back-to-back with\n `Schedule.Chain.PauseBetweenPhasesSec` between phases and\n `PauseBetweenCyclesSec` between full loops of the chain. Each\n `(CycleIndex, ChainedEventID)` pair is an **independent leaderboard\n instance** — its own top list, its own `RankRewards`/`Milestones` slate\n (shared config, independent progress), its own bracket set, its own token\n bucket. `LeaderboardRequest` (and hence some backend actions) can address a\n specific past phase via `CycleIndex`/`ChainedEventID`, but the SDK's\n everyday methods (`submitScore`, `getMyProgress`, `claimCycleReward`,\n `claimMilestone`) implicitly target the _current_ active phase; you\n encounter `ChainedEventID`/`CycleIndex` values mainly when reading them\n back off `LeaderboardGraceCycle` for a phase that just ended.\n\nFor all three modes, `CycleVersion` on `SubmitScoreResponse`/\n`GetLeaderboardResponse` is what the SDK cache compares against the cached\n`ScoreCycleVersion` to detect \"this is a new cycle, reset local per-cycle\nstate\" — for `Scheduled`/`Chained` instances this version is always `1`\n(instance identity lives in the doc ID, not the version number).\n\n`ScheduledWindow.ClaimGraceHours` / each `LeaderboardChainedEvent\n.ClaimGraceHours` set how long after that instance ends its rank reward and\nmilestones stay claimable — mirrors the `MilestoneClaimGraceHours` concept\ndescribed in [Milestones](#milestones--claim-modes) but is a _per-instance_\nwindow for Scheduled/Chained rather than the leaderboard-wide\n`MilestoneClaimGraceHours` used by Cyclic.\n\n---\n\n## Rank rewards\n\n```ts\ninterface LeaderboardRankReward {\n Rank?: string; // rank range: \"1\", \"2-5\", \"6-10\", \"11-50\", ...\n Rewards?: ResourceGrant; // grant-only — no cost side\n SeasonTierRewards?: SeasonTierRewardSet; // optional season-tier scaling overlay\n}\n```\n\n`Rank` is a string range matched against the player's final rank when the\ncycle/instance closes (`LeaderboardV2.IsRankInRange`: an exact integer like\n`\"1\"`, or an inclusive `\"start-end\"` range). `claimCycleReward` looks up\nwhich range the player's `Rank` (from `ClaimCycleRewardResponse.Rank`) falls\ninto and pays out that entry's `Rewards`. If `LinkedSeasonChainID` is set,\n`SeasonTierRewards` can scale or replace the base reward based on the\nplayer's tier in that season chain, resolved from a **snapshot**\n(`LeaderboardUserEntry.SeasonTierAtCycleEnd`) taken when the cycle/instance\nwas archived — not the player's current tier — so a season reset between\ncycle-end and claim doesn't change what a past cycle pays out. (`Bundles`\nreplace on match, `Multipliers` scale the base `Rewards.Amounts` — same\noverlay mechanic as `MilestoneDefinition.SeasonTierRewards`.)\n\nA player only has something to claim when `HasUnclaimedReward` (from\n`getMyProgress`) is true, mirrored into the cache as\n`UnclaimedRewardCycleVersion > 0`. If the player wasn't in the final top list,\nor their rank has no matching `RankRewards` entry, or the resolved grant is\nempty after premium filtering, the backend auto-marks the cycle as claimed\nwithout granting anything (`\"No unclaimed reward\"` on a later claim attempt,\nor a normal `claimCycleReward` success with an empty `Resources`) — don't\ntreat either as an error state to alarm the player over.\n\n---\n\n## Milestones & claim modes\n\n`Milestones` uses the SDK-wide `MilestoneDefinition` primitive (shared with\nQuest, TimedEvent, CoopEvent, DealOffer):\n\n```ts\ninterface MilestoneDefinition {\n MilestoneID?: string;\n DisplayName?: string;\n AssetPaths?: Record<string, string>;\n RequiredProgress?: number; // threshold checked against accumulated progress\n Rewards?: ResourceGrant;\n BonusRewards?: ResourceGrant; // paid during a bonus window, if applicable\n SeasonTierRewards?: SeasonTierRewardSet;\n SortOrder?: number;\n IsFeatured?: boolean;\n}\n```\n\nFor leaderboards, progress toward a milestone is tracked by a dedicated\n**leaderboard event token** (`EventTokenType.Leaderboard`, address\n`\"{leaderboardID}:{cycleKey}\"` for Cyclic or `\"{leaderboardID}:{instanceKey}\"`\nfor Scheduled/Chained) — its cumulative `Balance.TotalEarned` is the\nscore-earning activity in the current cycle/instance\n(`ScoreEarnedThisCycle` / the response's `MilestoneTokensEarned`), separate\nfrom the ranked `CurrentScore`. A `BestScore` leaderboard where you resubmit\na lower score still accrues milestone progress from the submission, because\nthe token accrues every submitted amount regardless of aggregation.\n\n`MilestoneClaimMode` controls _when_ a reached milestone can be claimed\n(`IDosGames.MilestoneClaimMode`, shared enum — **note the exact member\nnames**, they are not `\"AfterCycleEnd\"`):\n\n- **`Instant`** — claimable as soon as it's reached, any time during the\n cycle/instance, via `claimMilestone` / `claimMilestonesBatch`.\n- **`AfterEventEnd`** — every milestone must wait until the cycle/instance\n closes, and then only within `MilestoneClaimGraceHours` (Cyclic) or the\n instance's own `ClaimGraceHours` (Scheduled/Chained) of the close. Claiming\n early fails with `\"Milestone can only be claimed after cycle ends\"` (Cyclic)\n or `\"...after instance ends\"` (Scheduled/Chained).\n- **`FeaturedAfterEnd`** — hybrid: milestones with `IsFeatured: false` claim\n `Instant`-style any time; milestones with `IsFeatured: true` behave like\n `AfterEventEnd` (`\"Featured milestone can only be claimed after cycle\nends\"`).\n\n`GetMyProgressResponse.ClaimableGraceCycles` lists past cycles/instances\nstill inside the claim window:\n\n```ts\ninterface LeaderboardGraceCycle {\n CycleKey?: string;\n CycleEndUtc?: string;\n ClaimDeadlineUtc?: string; // CycleEndUtc + grace hours\n MilestoneTokensEarned?: number;\n ClaimableMilestoneIDs?: string[]; // milestones from that cycle/instance still claimable\n CycleIndex?: number; // Chained only\n ChainedEventID?: string; // Chained only\n}\n```\n\nOnce `ClaimDeadlineUtc` passes, that cycle/instance's milestones are no\nlonger claimable — check `ClaimableGraceCycles` (and its per-cycle deadline)\nrather than assuming a reached-but-unclaimed milestone stays claimable\nindefinitely. `MilestoneClaimGraceHours: 0` (the default) means Cyclic grace\nis disabled entirely — once a cycle rolls over, its milestones are gone for\ngood.\n\n---\n\n## Brackets\n\nWhen `BracketSize` is set (default `0` = disabled), the leaderboard splits\nplayers into fixed-size brackets rather than one global ranking (keeps ranks\nmeaningful at scale). A player is assigned to a bracket on their **first\nscore submission of the cycle**; resubmissions in the same cycle update their\nscore within that same bracket. A player's current bracket is `BracketID` on\nboth the cached progress and `GetLeaderboardResponse.BracketID`. `BracketID`\nresets to `null` whenever the cache detects a new cycle (see the score-submit\nreset rule above) — a new cycle reshuffles brackets. Bracket assignment\nretries up to 5 times server-side under contention (multiple players racing\nto fill the last slot of a bracket); in the rare case all retries are\nexhausted the player is left without a bracket for that cycle and sees an\nempty top list until their next submission.\n\n---\n\n## Score sources (ScoreSources / submitScoreFromSource)\n\n`ScoreSources: TriggerSource[]` lets a leaderboard accrue score automatically\nfrom game events, instead of relying solely on an explicit `submitScore`\ncall — the same unified `TriggerSource` primitive TimedEvent uses for token\nsources:\n\n```ts\ninterface TriggerSource {\n SourceType?: string; // e.g. \"DailyLogin\", \"ReferralInvite\", \"CustomAction\", board/quest/store/marketplace types\n BaseWeight?: number; // score granted per matching trigger\n ScaleWithRollMultiplier?: boolean; // multiply BaseWeight by the caller's rollMultiplier/volume\n TileTypeFilter?: string[];\n TileIndexFilter?: number[];\n ChanceOutcomeFilter?: string[];\n OutcomeFilter?: string[];\n Params?: Record<string, string>;\n Limits?: LimitSpec;\n}\n```\n\nTwo independent gates apply, both verified in `LeaderboardV2.cs`:\n\n1. **Aggregation gate** — `ScoreSources` are cumulative deltas\n (`BaseScore × multiplier`), so they're only ever matched/applied on\n `Sum`-aggregated leaderboards. On any other `ScoreAggregation`, both the\n server-side board/marketplace grant builders and\n `submitScoreFromSource` silently skip (no score change, no error) — for\n `BestScore`/`BestTime`/`LastValue` leaderboards, score must come from an\n explicit `submitScore` with the real value (a time, a high score), not\n accumulate off game events.\n2. **Client/server source gate** — `submitScoreFromSource` (the client-facing\n gateway) accepts ONLY `CustomAction` (with your own `Params`) and\n `DailyLogin` (a whitelist since 2026-09-28). Every other type is rejected\n with `\"Source '{type}' cannot be called from client.\"` — the server credits\n them itself when the action happens: board rolls, store purchases, quest\n claims, marketplace deals, new invited friends (`ReferralInvite`), currency\n spent/earned, real-money purchases, lootbox opens, crypto, rank rewards.\n3. **A raw number from the game needs `ClientScore`** — `submitScore`/\n `submitScoresBatch` are accepted ONLY by a board whose `ScoreSources`\n contains a `ClientScore` entry. Any other board (empty `ScoreSources` = not\n set up, server-fed like \"top inviters\", or client EVENTS only) refuses with\n `\"Leaderboard '{id}' does not accept a score from the game: add the\nClientScore score source to it.\"`; a batch naming such a board is refused\n whole. `ClientScore` is a permission, not an event — it grants nothing and\n `submitScoreFromSource` refuses it. Its `Limits` are the caps on the\n game's number (the client is not trusted — they are the only limit on a\n forged score), a submission over a cap is REFUSED, not trimmed:\n - `PerActivationCap` — max score of ONE submission;\n - `DailyCap` / `DailyWeightCap` — submissions / total score per UTC day;\n - `CooldownSeconds` — pause between submissions;\n - `MaxPerWindow` + `WindowSeconds` — submissions per window;\n - `TotalCap` — submissions for all time.\n\n Set caps the game can really reach (the starter `weekly_score`: 100 per\n round, 10 s cooldown, 300 rounds a day). When you add a board the game\n submits to, add `{ \"SourceType\": \"ClientScore\", \"Limits\": { … } }` — without\n it every submit fails. Server scoring (event sources, leaderboard tokens\n granted by rewards) is not affected by these caps.\n\nIf no configured `ScoreSources` entry matches the given `sourceType`/\n`outcome`/`params` (or `BaseWeight <= 0`), the call is a **benign no-op**:\n`ok: true` with `{ NewScore: 0, CycleVersion: 0 }`, not an error — the\nbackend deliberately doesn't leak \"does this source exist\" information via\nan error. Don't render a `NewScore` toast without checking it's `> 0`.\n\n---\n\n## Responses\n\n```ts\ninterface LeaderboardUserEntry {\n UserID: string;\n Score?: number;\n Rank?: number;\n LastScoreUpdateUtc?: string;\n IpHash?: string;\n PublicProfile?: UserPublicDataModel; // display name/avatar for rendering the row\n SeasonTierAtCycleEnd?: number; // this entry's season tier when the cycle closed (archive snapshot only; 0 while cycle is active)\n}\n\ninterface GetLeaderboardResponse {\n LeaderboardID: string;\n CycleVersion?: number;\n TotalParticipants?: number;\n CycleEndUtc?: string;\n TopUsers?: LeaderboardUserEntry[]; // length capped at TopUsersLimit\n BracketID?: string; // set when BracketSize splits the board\n}\n\ninterface GetMyProgressResponse {\n LeaderboardID: string;\n CurrentScore?: number;\n LastKnownRank?: number;\n HasUnclaimedReward?: boolean;\n ScoreEarnedThisCycle?: number;\n MilestoneTokensEarned?: number;\n NextMilestone?: MilestoneDefinition; // next unreached milestone, or absent if none left\n HasUnclaimedMilestone?: boolean;\n ClaimableGraceCycles?: LeaderboardGraceCycle[];\n}\n\ninterface SubmitScoreResponse {\n LeaderboardID: string;\n NewScore: number;\n CycleVersion: number;\n}\n\ninterface ClaimCycleRewardResponse {\n ServerTimeUtc: string;\n LeaderboardID: string;\n Rank?: number;\n Resources?: ResourceOperation;\n}\n\ninterface ClaimLeaderboardMilestoneResponse {\n ServerTimeUtc: string;\n LeaderboardID: string;\n MilestoneID: string;\n Resources?: ResourceOperation;\n}\n\ninterface LeaderboardOverview {\n LeaderboardID: string;\n Leaderboard?: BatchItemResult<GetLeaderboardResponse> | null;\n Progress?: BatchItemResult<GetMyProgressResponse> | null;\n}\n\ninterface ClaimAllRewardsResult {\n LeaderboardID: string;\n CycleReward?: BatchItemResult<ClaimCycleRewardResponse> | null;\n Milestones?: BatchItemResult<ClaimLeaderboardMilestoneResponse>[] | null;\n}\n```\n\n---\n\n## Batch semantics\n\nAll batch/array-of-IDs inputs (`LeaderboardIDs`, `Milestones` pairs, `Scores`\npairs) go through the same normalization on the backend\n(`BatchSupport.NormalizeIDs` / equivalent inline dedup in\n`SubmitScoresBatch`/`ClaimMilestonesBatch`): entries are trimmed, empty/\ninvalid ones dropped, duplicates removed (first occurrence wins, by ID for\nsingle-value batches or by the natural composite key — `LeaderboardID` for\nscores, `` `${LeaderboardID}:${MilestoneID}` `` for milestone pairs), then the\nlist is **clamped to 50 items**. Anything past the 50th is **silently\ndropped** — it never appears in the result array at all, success or failure.\nThe SDK does not pre-validate, warn, or chunk this for you.\n\n**Atomicity is per-subset, not per-call**, and this is the biggest surprise\nrelative to other v2 modules' batch actions:\n\n- `submitScoresBatch`: leaderboards that are `Cyclic` **and** not bracketed\n (`BracketSize == 0`) are all folded into a **single Mongo transaction** —\n either every one of those items' score+top-list update commits, or none\n do. Leaderboards that are bracketed, or `Scheduled`/`Chained`, are instead\n processed **one at a time** by internally re-calling the same code path as\n the single `submitScore` action — each such item is its own independent\n operation, can fail on its own, and does not affect the atomic group's\n outcome (or vice versa).\n- `claimCycleRewardsBatch` / `claimMilestonesBatch` / `claimAllRewardsBatch`:\n same split. `Cyclic` (non-bracketed or bracketed — bracket archives are\n just a different lookup, still part of the cyclic path) targets are merged\n into **one atomic claim** (`ApplyResourceOperationAtomicAsync` with all\n their patches/filters ANDed and their `Resources` grants merged — see\n `ApplyClaimBatchAsync`/`BatchSupport.ApplyMergedClaimAsync`). `Scheduled`/\n `Chained` targets are each claimed independently (one atomic operation\n _per instance_, since instances have no shared version to gate a joint\n transaction on), and for `claimAllRewardsBatch`/`claimCycleRewardsBatch` a\n non-cyclic leaderboard may internally claim **several ended-but-in-grace\n instances at once**, merging their resources into one aggregated\n `BatchItemResult` entry.\n- Where a merged claim did pay out resources, the combined `ResourceOperation`\n is attached to only the **first** successful item that had a non-empty\n grant in each atomic group (`resourcesAttached` flag) — don't expect every\n successful item in a multi-leaderboard claim to carry its own slice of\n `Resources`; read the aggregate off whichever item has it and trust the\n SDK's applied-balance side effects for the rest.\n- Invalid items (unknown leaderboard/milestone, \"already claimed\", \"no\n unclaimed reward\", timing violations) are filtered out **before** the\n atomic transaction is built, so one stale/bad entry in a batch never blocks\n the others from committing.\n\nIdempotency for single calls follows the platform-wide `RelatedEntityID`\npattern (`ResourceService.ResolveRelatedEntityID`): cycle\nclaims key off `lb_cycle_{leaderboardID}_v{cycleVersion}` (or\n`lb_inst_{instanceDocID}` for Scheduled/Chained), milestone claims key off\n`lb_milestone_{tokenEntityID}_{milestoneID}`. Batch claims build one\ncombined, deterministic reason from the sorted keys of everything actually\nincluded in that transaction (`BatchSupport.BuildBatchReason`) — retries with\nthe same effective item set are idempotent; changing the item set changes the\nreason.\n"
9
9
  }
10
10
  ]
11
11
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "multiplayer-realtime",
3
3
  "description": "Build realtime peer-to-peer multiplayer in a game on the iDosGames TypeScript SDK via client.multiplayer (MultiplayerService): create, join, list and close rooms, relay WebRTC offers/answers and ICE candidates, poll for signal/chat/presence envelopes, in-room chat, ready/lobby state, host handover (claimHost), kick/ban; plus the ready WebRTC transport RealtimeSession from @idosgames/core/realtime. It is a signaling and room relay, not a gameplay or economy module. Use this whenever the user wants realtime rooms or lobbies, WebRTC signaling, peer-to-peer setup, in-room chat or ready-check UIs, or touches client.multiplayer, MultiplayerService, RoomSnapshotResponse, RealtimeEnvelope or RealtimeSession — even without naming the module. In an app with the base (lobby) a game does NOT open rooms: it registers in ctx.multiplayer (registerGame) and takes the room in start(room) — read the first section.",
4
- "content": "---\nname: multiplayer-realtime\ndescription: >-\n Build realtime peer-to-peer multiplayer in a game on the iDosGames\n TypeScript SDK via client.multiplayer (MultiplayerService): create, join,\n list and close rooms, relay WebRTC offers/answers and ICE candidates, poll for\n signal/chat/presence envelopes, in-room chat, ready/lobby state, host\n handover (claimHost), kick/ban; plus the ready WebRTC transport\n RealtimeSession from @idosgames/core/realtime. It is a signaling and room\n relay, not a gameplay or economy module. Use this whenever the user wants\n realtime rooms or lobbies, WebRTC signaling, peer-to-peer setup, in-room chat\n or ready-check UIs, or touches client.multiplayer, MultiplayerService,\n RoomSnapshotResponse, RealtimeEnvelope or RealtimeSession — even without\n naming the module. In an app with the base (lobby) a game does NOT open rooms:\n it registers in ctx.multiplayer (registerGame) and takes the room in\n start(room) — read the first section.\n---\n\n# Multiplayer realtime signaling (iDosGames TS SDK)\n\nMultiplayer is a **realtime WebRTC signaling relay**, not a gameplay or\neconomy module. It gives you room lifecycle (create/join/leave/list/close) and\na message relay for two things peer-to-peer games need: SDP offer/answer + ICE\ncandidate exchange (so browsers/clients can establish a direct WebRTC\nconnection), and optional server-relayed chat/presence. The actual game state\nsync between players happens over the WebRTC connection you establish\nyourself — this module only gets players into the same room and helps them\nfind each other.\n\n**No currency/item coupling, no per-player cache mirror.** Unlike every other\nmodule in this SDK, no Multiplayer action ever returns a `ResourceOperation`\nor writes to `client.data.user.state` — there is nothing to mirror into a\nlocal player-state cache. Room state itself is ephemeral (Redis, TTL-based, no\nMongo document), so the SDK surface here is pure transport + event-emission:\ncall a method, get a response, emit an event, done. There is no\n`user:multiplayerUpdated` event and no `client.data.user.state.Multiplayer` —\ndon't look for either. The one piece of _config_ this module does expose —\n`MultiplayerDefinitions` (feature switch, topology/chat defaults, room-size\ncap, TTL, payload limits, ICE servers) — rides in on the title's public\nconfig bundle rather than a dedicated fetch call; see\n[Reading limits from config](#reading-limits-from-config).\n\nThis skill is for **using** the production `MultiplayerService`, not for\nporting or extending it. **Don't hand-roll WebRTC** on top of it: the SDK ships\n`RealtimeSession` (`@idosgames/core/realtime`, see\n[Recommended: RealtimeSession](#recommended-realtimesession)) which owns the\n`RTCPeerConnection`s, the poll loop and the signaling etiquette the backend\nexpects. The raw methods below are for lobby UI and for the rare custom\ntransport. Server rejections (room full, wrong join\ncode, not the owner, payload too large) are backend rules — surface them,\ndon't try to pre-validate every one of them client-side.\n\n## A game in an app with the base: `ctx.multiplayer` — do NOT open rooms yourself\n\nIn a project built on the base (lobby) the ROOMS belong to the multiplayer system (the `multiplayer`\nmodule): the list of rooms, \"Create a room\", joining by invite, members, \"Ready\", chat — and the\n`RealtimeSession`, opened already in the lobby. A game never calls `createRoom`/`joinRoom`/\n`RealtimeSession.create|join`; it declares how it plays online and TAKES the room:\n\n```ts\n// module.ts, in setup()\nctx.multiplayer.registerGame({\n mode: \"my-game\", // the game's route id — the room's game\n topology: \"Host\", // \"Host\" (star) | \"Mesh\"\n chatMode: \"P2P\", // the game has its own chat; \"ServerRelay\" = the lobby's chat\n maxPlayers: 8,\n joinInProgress: true, // a player may join a running game (your late-joiner path)\n settings: [\n // the host picks them in \"Create a room\"\n {\n id: \"mode\",\n label: \"Mode\",\n kind: \"choice\",\n default: \"coop\",\n options: [\n { value: \"coop\", label: \"Co-op\" },\n { value: \"pvp\", label: \"PvP\" },\n ],\n },\n { id: \"cheats\", label: \"Cheats for all\", kind: \"toggle\", default: false },\n ],\n // Called on EVERY member's device after the app switched to the game's mode (the host first).\n start: async (room) => {\n const game = await whenGameMounted(); // the scene may mount on this very switch\n game.playOnline(room.session, {\n host: room.isHost && !room.lateJoin, // opens its world for the others\n settings: room.settings, // { mode: \"coop\", cheats: false }\n leave: room.leave, // leave through the lobby, never session.leave()\n });\n },\n});\n```\n\n- The lobby then draws \"With friends\" next to the game's \"Play\" (its rooms), and the rooms screen\n offers the game in \"Create a room\". A game's own \"Play with friends\" button:\n `ctx.features.open(\"multiplayer\", { mode: \"my-game\", create: true })` — from inside the game it\n switches to the lobby by itself.\n- Show that button only while `ctx.features.get(\"multiplayer\")?.available` — without the system the\n game is single-player, nothing else changes.\n- Leave with `room.leave()`: the lobby owns the room and forgets it together with the session.\n- Messages on the session are yours from `start` on; the lobby's own start message goes out before\n any of yours on the ordered channel and never reaches the game.\n- Reference implementation: `modules/voxelcraft` (`module.ts` → `registerGame`, `net/NetGame.ts` →\n `NetGame.fromRoom`, `main.ts` → `Game.acceptRoom`).\n\nEverything below — `client.multiplayer` and `RealtimeSession` directly — is for an app WITHOUT the\nbase, or for writing the rooms system itself.\n\n## Mental model\n\n1. One player **creates a room** (picks topology/chat mode/visibility/max\n players, optionally a join code); others **join** by room id (+ join code\n if private). **A player can only be in one room at a time** — creating or\n joining while already in a live room fails; leave first.\n2. Each member **polls** the room on a cursor (`LastSeq`) to receive a stream\n of envelopes: other members' signals, chat, presence changes\n (join/leave/host-migration), and room-closed notices. Polling is also the\n heartbeat — it's what keeps your presence and the room's Redis TTL alive.\n3. To establish a direct connection, members exchange SDP offer/answer and ICE\n candidates by calling **`sendSignal`** — the server just relays the\n `payload` string to a specific `targetUserID`; it never inspects or\n validates WebRTC semantics.\n4. Optional **`sendChat`** (only meaningful when the room's `ChatMode` is\n `ServerRelay` — under `P2P` chat should go over your own DataChannel\n instead) and **`setMemberState`** (ready flags + a free-form string map)\n round out lobby UX.\n5. Someone **leaves**, or the owner/host **closes** the room; on host\n departure the server may migrate to a `NewHostUserID` if members remain.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst mp = client.multiplayer; // the MultiplayerService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args — missing room id/name/target/kind), `\"unauthorized\"`,\n`\"throttled\"` (fired the same endpoint again inside the transport's throttle\nwindow), `\"connection\"` (transient, offer Retry), `\"validation\"`\n(response/schema drift), or `\"server\"` (the backend rejected it — `error`\ncarries a human-readable reason; see the verbatim strings below).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------ |\n| `createRoom(roomName, options?)` | Create a room; caller becomes owner/host. | `RoomSnapshotResponse` |\n| `joinRoom(roomID, joinCode?)` | Join an existing room. | `RoomSnapshotResponse` |\n| `leaveRoom(roomID, { successorUserID?, keepalive? }?)` | Leave a room (may trigger host migration or close it if empty). A leaving host may name the successor; `keepalive` lets the request outlive the page (`pagehide`). | `LeaveRoomResponse` |\n| `listRooms(page?, pageSize?)` | Page through public rooms (default `page=0, pageSize=20`, server caps `pageSize` at 50). | `RoomsPageResponse` |\n| `getRoom(roomID)` | Fetch a fresh snapshot of one room. | `RoomSnapshotResponse` |\n| `closeRoom(roomID)` | Owner/host-only: force-close a room. | `LeaveRoomResponse` |\n| `poll(roomID, lastSeq)` | Long-poll cursor: get every envelope with `Seq > lastSeq`. | `PollResponse` |\n| `sendSignal(roomID, targetUserID, signalKind, payload)` | Relay an SDP offer/answer/ICE candidate to one member. | `PublishResponse` |\n| `sendChat(roomID, chatText)` | Server-relayed chat message (only when `ChatMode: ServerRelay`). | `PublishResponse` |\n| `setMemberState(roomID, ready?, memberState?)` | Publish this member's ready flag / free-form lobby state. | `PublishResponse` |\n| `claimHost(roomID)` | Take the host role from a host that stopped polling (silent longer than `MemberTimeoutMs`). The silent host is evicted; `PlayerLeft` (Kind `\"timeout\"`) + `HostChanged` go to everyone. Exactly one claimant wins. | `RoomSnapshotResponse` |\n| `kickMember(roomID, targetUserID, ban?)` | Owner/host-only: remove a member (`PlayerLeft`, Kind `\"kicked\"`); `ban` also puts them on the room's ban list (survives host changes, dies with the room). Their TURN credentials are revoked. | `KickMemberResponse` (`Removed`, `Banned`) |\n| `unbanMember(roomID, targetUserID)` | Owner/host-only: take a player off the ban list. | `UnbanMemberResponse` (`WasBanned`) |\n\n`createRoom`'s `options`: `{ topology?: MultiplayerTopology (\"Mesh\"|\"Host\"),\nchatMode?: MultiplayerChatMode (\"ServerRelay\"|\"P2P\"), maxPlayers?: number,\nvisibility?: RoomVisibility (\"Public\"|\"Private\"), joinCode?: string }`. Any\nfield you omit falls back to the title's configured defaults\n(`MultiplayerDefinitions.DefaultTopology`/`DefaultChatMode`), and an override\nis only honored if the title allows it\n(`AllowCreatorTopologyOverride`/`AllowCreatorChatOverride`) — otherwise the\ncall fails server-side with `\"Topology override is not allowed for this\ntitle.\"` / `\"Chat mode override is not allowed for this title.\"`.\n`maxPlayers` is clamped down to the title's `MaxPlayersPerRoom` hard cap; you\ncan only ask for _fewer_, never more.\n\n`closeRoom` succeeds for either the room's `OwnerUserID` **or** its current\n`HostUserID` (these can diverge after a host migration) — anyone else gets\n`\"Only the room owner or host can close the room.\"`.\n\nEvery method still emits its event on success even though nothing is cached —\nevents are your only hook here, there's no cache to read afterward.\n\n### Server error strings (verbatim)\n\nThese are exact `error` text from the backend (`RoomService.cs` /\n`SignalingService.cs`) — match on substrings if you need to branch, but the\nsafe default is just to display `result.error` as-is:\n\n- `\"Multiplayer is disabled for this title.\"` — title-wide kill switch is off.\n- `\"You are already in room {roomId}. Leave it first.\"` — one-active-room rule,\n on both `createRoom` and `joinRoom`.\n- `\"RoomID is required.\"` / `\"TargetUserID is required.\"` / `\"SignalKind is\nrequired.\"` — same validation the SDK does client-side; you'll only see\n these from a raw/bypassed call.\n- `\"Invalid or missing join code.\"` — private room join code mismatch.\n- `\"Room not found.\"` — unknown room id, or a **private** room you aren't a\n member of (privacy is enforced by returning the same not-found error, not a\n distinct \"forbidden\").\n- `\"Room is closed.\"` / `\"Room is full.\"` — join-time rejections.\n- `\"Only the room owner or host can close the room.\"`\n- `\"You were removed from this room.\"` — `joinRoom` by a banned player.\n- `\"The host is still active.\"` — `claimHost` while the host still polls\n (retry later; only a host silent for `MemberTimeoutMs` can be replaced).\n- `\"Only the room owner or host can remove players.\"` / `\"Cannot kick\nyourself.\"` / `\"Cannot remove the host.\"` / `\"Cannot remove the room owner.\"`\n — `kickMember` / `unbanMember`.\n- `\"Room no longer exists.\"` / `\"You are not a member of this room.\"` — from\n `poll`, `sendSignal`, `sendChat`, `setMemberState` once you've dropped out of\n the member hash (TTL expiry, or someone else removed you).\n- `\"Target user is not in this room.\"` — `sendSignal` to a non-member.\n- `\"Cannot signal yourself.\"`\n- `` `Signal payload exceeds {N} bytes.` `` — `MaxSignalPayloadBytes` (default 16384) exceeded.\n- `` `Chat text exceeds {N} characters.` `` — `MaxChatTextLength` (default 500)\n exceeded.\n- `\"Chat is peer-to-peer in this room; send it over the DataChannel.\"` —\n called `sendChat` while `ChatMode` is `P2P`.\n- `\"Corrupted member record.\"` — internal deserialization failure on\n `setMemberState`; treat as transient/report.\n\n## Reading limits from config\n\nThere's no `getMultiplayerDefinitions()` call — `MultiplayerDefinitions`\ntravels as part of the title's public config bundle, not a per-module\ndefinitions fetch. It's populated by `client.title.getTitlePublicConfiguration()`\n(or already present after login, since the bootstrap `ClientState` includes\nthe title config):\n\n```ts\nimport type { MultiplayerDefinitions } from \"@idosgames/core\";\n\nawait client.title.getTitlePublicConfiguration(); // if not already loaded\nconst cfg = client.data.config.titlePublicConfiguration?.Multiplayer as\n MultiplayerDefinitions | undefined;\n\ncfg?.MaxPlayersPerRoom; // hard cap enforced server-side\ncfg?.MaxChatTextLength;\ncfg?.MaxSignalPayloadBytes;\ncfg?.HeartbeatIntervalMs; // recommended poll cadence, not enforced\ncfg?.MemberTimeoutMs; // client-side presumed-offline threshold\n```\n\nRead it for building config-driven UI (create-room forms that pre-fill/clamp\n`maxPlayers`, a chat box that disables itself when `ChatMode` will be `P2P`,\netc.) — the server enforces the real limits regardless of what you show.\n\n## Events\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn. There is no\ncoarse `user:*Updated`/`user:anyUpdated` companion for any of these — they\ndon't touch the shared cache.\n\n- `multiplayer:roomCreated` → `RoomSnapshotResponse`\n- `multiplayer:roomJoined` → `RoomSnapshotResponse`\n- `multiplayer:roomLeft` → `LeaveRoomResponse`\n- `multiplayer:roomsListed` → `RoomsPageResponse`\n- `multiplayer:roomLoaded` → `RoomSnapshotResponse`\n- `multiplayer:roomClosed` → `LeaveRoomResponse`\n- `multiplayer:polled` → `PollResponse`\n- `multiplayer:signalSent` → `PublishResponse`\n- `multiplayer:chatSent` → `PublishResponse`\n- `multiplayer:memberStateSet` → `PublishResponse`\n- `multiplayer:hostClaimed` → `RoomSnapshotResponse`\n- `multiplayer:memberKicked` → `KickMemberResponse`\n- `multiplayer:memberUnbanned` → `UnbanMemberResponse`\n\n```ts\nconst off = client.on(\"multiplayer:roomClosed\", (r) => {\n if (r.Closed) navigateToLobbyList();\n});\n// later: off();\n```\n\nFor the full shape of `RoomSnapshotResponse`, `RealtimeEnvelope`,\n`RoomMemberView`, and `MultiplayerDefinitions`, see\n[references/data-model.md](references/data-model.md).\n\n## Recommended: RealtimeSession\n\n`@idosgames/core/realtime` is the WebRTC transport on top of this module. Use it\ninstead of writing the offer/answer/ICE dance yourself:\n\n```ts\nimport { RealtimeSession } from \"@idosgames/core/realtime\";\n\nconst res = await RealtimeSession.create(client, \"My world\", {\n topology: \"Host\", // star: guests link to the host only\n visibility: \"Public\",\n maxPlayers: 8,\n});\n// guests: await RealtimeSession.join(client, roomID, joinCode?)\nif (!res.ok) return showError(res.error);\nconst session = res.data;\n\nsession.on(\"peerOpen\", ({ peerId }) => sendWorldSnapshot(peerId));\nsession.on(\"message\", ({ from, channel, data }) => handle(from, channel, data));\nsession.on(\"hostChanged\", ({ hostId }) => {\n if (hostId === session.selfId) becomeHost();\n});\nsession.on(\"removed\", ({ reason }) => backToMenu(reason)); // kicked / banned\nsession.on(\"roomClosed\", () => backToMenu(\"closed\"));\n\nsession.send(peerId, \"rel\", bytes); // ordered + reliable, fragmented into 16 KB frames\nsession.broadcast(\"fast\", poseBytes); // unordered, no retransmits: positions etc.\n```\n\nWhat it does for you:\n\n- **Two DataChannels per link**: `rel` (ordered, reliable; large messages are\n fragmented) and `fast` (unordered, `maxRetransmits: 0`). `send` returns\n `false` when the link is not open, a `fast` message is too big, or `rel` is\n backed up — drop or retry, don't queue forever.\n- **Cheap polling**: 1 s only while something is being set up, then the host\n polls every 2.5 s (to see newcomers) and everyone else every 20 s (just to\n stay a member). Signals go through a single-flight, per-peer batched outbox,\n and descriptions carry their ICE candidates — about one `SendSignal` per\n side per connection.\n- **Liveness**: own keepalive on `fast` (a silent peer is closed after ~6 s),\n RTT via `linkStats(peerId)` (measured every ~5 s even under load),\n reconnect with back-off.\n- **Host handover**: the host calls `setSuccessor(userId)`; `leave()` (and\n page close — a `keepalive` request on `pagehide`) passes it to the server.\n If the host tab dies instead, the successor calls `session.claimHost()`\n once the host has been silent for the title's `MemberTimeoutMs`.\n `setExtraPeers([...])` keeps standby links (e.g. every guest to the\n successor) so the handover does not wait for signaling.\n- **Moderation**: `session.kick(userId, ban?)`, `session.unban(userId)`. After\n a successful kick the host sends the kicked player a \"check your membership\"\n notice over the link; that player polls at once and gets `removed` within\n about a round trip. The notice is only a hint — the SERVER confirms the\n removal, so a forged notice costs one extra poll and nothing else.\n- **TURN**: `IceServers` of the snapshot are used as is. When the engine has\n Cloudflare TURN configured (env `CLOUDFLARE_TURN_KEY_ID` / `CLOUDFLARE_TURN_API_TOKEN` on the\n engine — not title config) members get short-lived TURN credentials in the\n snapshot; otherwise STUN only.\n\nA full game built on it: `modules/voxelcraft/src/net/` (NetGame — dimension\nowners, replicas, host migration with a shadow copy of the world).\n\n## Recipes\n\n### Create or join a room\n\n```ts\nconst created = await client.multiplayer.createRoom(\"Match 42\", {\n topology: \"Mesh\",\n chatMode: \"ServerRelay\",\n maxPlayers: 4,\n visibility: \"Private\",\n joinCode: \"AB12\",\n});\nif (!created.ok) return showError(created.error);\nconst { RoomID, Seq, IceServers, Members } = created.data;\n\n// another player:\nconst joined = await client.multiplayer.joinRoom(RoomID, \"AB12\");\nif (!joined.ok) return showError(joined.error); // e.g. wrong code, room full\n```\n\n`IceServers` on the snapshot is the STUN/TURN config for your `RTCPeerConnection`\n— pass it straight through (if the title has none configured, the backend\nfalls back to public STUN servers, so this array is never empty). TURN\ncredentials, when the engine has Cloudflare TURN configured, are included only\nfor room members and expire after ~12 h — never put a static TURN login into\nthe title's public config.\n`Seq` is your starting poll cursor. Remember the one-active-room rule: a\nsecond `createRoom`/`joinRoom` call while already in a room fails with\n`\"You are already in room {roomId}. Leave it first.\"` — call `leaveRoom` (or\ncheck your own app state) before switching rooms.\n\n### The polling loop (signals, chat, presence)\n\nPoll is a cursor-based long-poll: pass the last `Seq` you've seen, get back\neverything newer, and each response's `MaxSeq` becomes your next cursor. Each\npoll call also renews your presence heartbeat and the room's TTL server-side\n— an idle client that stops polling will eventually look disconnected to\nothers and the room itself can expire.\n\n```ts\nlet lastSeq = created.data.Seq ?? 0;\nlet polling = true;\n\nasync function pollLoop(roomID: string) {\n while (polling) {\n const res = await client.multiplayer.poll(roomID, lastSeq);\n if (!res.ok) {\n if (res.reason === \"connection\") continue; // transient, retry\n break; // unauthorized/server error (\"Room no longer exists.\", etc) — stop and surface it\n }\n for (const env of res.data.Envelopes ?? []) {\n switch (env.Type) {\n case \"Signal\":\n // env.Kind: \"offer\" | \"answer\" | \"candidate\"; env.FromUserID, env.Payload\n handleSignal(env.FromUserID, env.Kind, env.Payload);\n break;\n case \"Chat\":\n appendChatMessage(env.FromUserID, env.Payload);\n break;\n case \"PlayerJoined\":\n case \"PlayerLeft\":\n case \"MemberState\":\n refreshRoomMembers(roomID);\n break;\n case \"HostChanged\":\n break; // env.Payload / a follow-up getRoom() carries new host\n case \"RoomClosed\":\n polling = false;\n break;\n }\n }\n lastSeq = res.data.MaxSeq ?? lastSeq;\n }\n}\n```\n\nThere's no push/websocket transport here — polling cadence is your call. The\ntitle's `MultiplayerDefinitions.HeartbeatIntervalMs` (default 1000ms) is a\n_recommended_ interval only, not enforced — poll too fast and you risk\n`reason: \"throttled\"` at the transport layer; too slow and signaling feels\nlaggy, and other members' `LastSeenMs` will look stale to you. Use the\nrecommended interval as your default; back off on `\"connection\"` errors\nrather than hot-looping.\n\n### Exchange a WebRTC offer/answer\n\n```ts\n// caller side, after creating an RTCPeerConnection and an offer:\nconst offer = await pc.createOffer();\nawait pc.setLocalDescription(offer);\nawait client.multiplayer.sendSignal(\n roomID,\n targetUserID,\n \"offer\",\n JSON.stringify(offer),\n);\n\n// callee side, from the poll loop's \"Signal\"/\"offer\" case:\nconst offer = JSON.parse(env.Payload);\nawait pc.setRemoteDescription(offer);\nconst answer = await pc.createAnswer();\nawait pc.setLocalDescription(answer);\nawait client.multiplayer.sendSignal(\n roomID,\n env.FromUserID,\n \"answer\",\n JSON.stringify(answer),\n);\n\n// both sides, as ICE candidates trickle in:\npc.onicecandidate = (e) => {\n if (e.candidate) {\n client.multiplayer.sendSignal(\n roomID,\n targetUserID,\n \"candidate\",\n JSON.stringify(e.candidate),\n );\n }\n};\n```\n\n`signalKind` is a free-form string (`\"offer\"`/`\"answer\"`/`\"candidate\"` by\nconvention) and `payload` is an opaque string — the server relays both without\ninspecting them, so encoding is entirely up to you (JSON-stringify is the\nobvious choice). Keep the serialized payload under the title's\n`MaxSignalPayloadBytes` (default 16384) — an oversized payload is rejected\nwith `` `Signal payload exceeds {N} bytes.` `` before it's ever relayed, and\nyou can't signal yourself (`\"Cannot signal yourself.\"`) or a user who isn't\ncurrently a room member (`\"Target user is not in this room.\"`).\n\n### Lobby ready-check and leaving\n\n```ts\nawait client.multiplayer.setMemberState(roomID, true, { loadout: \"red\" });\n// other members see this via a \"MemberState\" envelope on their next poll,\n// then re-fetch with getRoom(roomID) to read updated Members[].Ready/State.\n\nconst left = await client.multiplayer.leaveRoom(roomID);\nif (left.ok && left.data.Closed) {\n // room was empty/owner left with no migration target — it's gone\n} else if (left.ok && left.data.NewHostUserID) {\n // host migrated; that member is now authoritative for Host topology\n}\n```\n\n## Gotchas\n\n- **Nothing here touches `client.data.user.state`.** Don't look for a cached\n room state or resource balances changing — read everything from method\n responses and poll envelopes, and keep your own local room/member state in\n your app layer. The one thing that _is_ cached is the title-wide\n `MultiplayerDefinitions` config, and only after you fetch the title config\n (see [Reading limits from config](#reading-limits-from-config)).\n- **One room per player, enforced server-side.** `createRoom` and `joinRoom`\n both reject with `\"You are already in room {roomId}. Leave it first.\"` if\n you're already a live member elsewhere — there's no implicit \"leave old,\n join new.\"\n- **Room state is ephemeral.** No persistent document backs a room — it's\n Redis, TTL-based (`RoomTtlSeconds`, default 3600s, renewed on every\n interaction including `poll`). A room can disappear if nobody interacts\n with it; don't assume a `RoomID` is valid indefinitely. A title may also\n set a hard lifetime (`MaxRoomLifetimeSeconds`, counted from creation, NOT\n renewed by activity): when it passes, members receive `RoomClosed` with\n `Kind: \"expired\"` mid-game — save progress and offer \"new room\".\n- **`sendChat` only works under `ChatMode: ServerRelay`.** Under `P2P` chat\n mode, send chat over your own WebRTC DataChannel instead — calling\n `sendChat` in `P2P` mode fails with `\"Chat is peer-to-peer in this room;\nsend it over the DataChannel.\"` (check the room snapshot's `ChatMode`\n before wiring a chat send button to it).\n- **`closeRoom` works for the owner or the current host, not just the\n original creator.** After a host migration the new host can also close the\n room; anyone else gets `\"Only the room owner or host can close the room.\"`.\n Regular non-host, non-owner members only have `leaveRoom` (which itself may\n close the room if it empties out).\n- **Private rooms 404 instead of 403.** `getRoom`/`poll`/etc. on a private\n room you're not a member of returns the same `\"Room not found.\"` as a\n genuinely unknown id — the backend doesn't leak room existence to\n non-members.\n- **No automatic eviction — except via `claimHost`.** `RoomMemberView.LastSeenMs`\n is a last-heartbeat timestamp for clients to compare against\n `MultiplayerDefinitions.MemberTimeoutMs` themselves (e.g. to gray out a\n presumed-disconnected member). The server never evicts a stale member on\n its own; the one exception is `claimHost`, which removes a HOST that has\n been silent longer than `MemberTimeoutMs` and hands the role to the caller.\n- **Poll cadence is uncapped by the SDK — you own the rate.** There's no\n built-in backoff or interval; hitting the same endpoint faster than the\n transport's throttle window returns `reason: \"throttled\"` rather than\n duplicating work. `HeartbeatIntervalMs` is the title's _recommended_\n interval, not an enforced one. Design your loop's interval deliberately\n rather than polling in a tight `while(true)`.\n- **`Payload`/`signalKind` are opaque strings.** The server does not validate\n WebRTC SDP/ICE structure — malformed payloads only fail on the _receiving_\n client when it tries to parse/apply them, not at the relay. It does,\n however, enforce a byte-size ceiling (`MaxSignalPayloadBytes`) before\n relaying at all.\n- **Guard against double-submit** on room actions (create/join/close): each\n call is a distinct request, so two separate calls are two real operations\n server-side (e.g. two `createRoom` calls can both succeed as two different\n rooms if the first response is slow). Disable the control while a call is\n in flight.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full shape of\n`RoomSnapshotResponse`, `RoomMemberView`, `RealtimeEnvelope`/`EnvelopeType`,\n`MultiplayerDefinitions`, and the request/action surface, plus the room\nlifecycle state machine (Open → Closed, host migration, TTL expiry). Read it\nwhen building richer room/lobby UI or wiring up the poll loop's envelope\nhandling in detail.\n",
4
+ "content": "---\nname: multiplayer-realtime\ndescription: >-\n Build realtime peer-to-peer multiplayer in a game on the iDosGames\n TypeScript SDK via client.multiplayer (MultiplayerService): create, join,\n list and close rooms, relay WebRTC offers/answers and ICE candidates, poll for\n signal/chat/presence envelopes, in-room chat, ready/lobby state, host\n handover (claimHost), kick/ban; plus the ready WebRTC transport\n RealtimeSession from @idosgames/core/realtime. It is a signaling and room\n relay, not a gameplay or economy module. Use this whenever the user wants\n realtime rooms or lobbies, WebRTC signaling, peer-to-peer setup, in-room chat\n or ready-check UIs, or touches client.multiplayer, MultiplayerService,\n RoomSnapshotResponse, RealtimeEnvelope or RealtimeSession — even without\n naming the module. In an app with the base (lobby) a game does NOT open rooms:\n it registers in ctx.multiplayer (registerGame) and takes the room in\n start(room) — read the first section.\n---\n\n# Multiplayer realtime signaling (iDosGames TS SDK)\n\nMultiplayer is a **realtime WebRTC signaling relay**, not a gameplay or\neconomy module. It gives you room lifecycle (create/join/leave/list/close) and\na message relay for two things peer-to-peer games need: SDP offer/answer + ICE\ncandidate exchange (so browsers/clients can establish a direct WebRTC\nconnection), and optional server-relayed chat/presence. The actual game state\nsync between players happens over the WebRTC connection you establish\nyourself — this module only gets players into the same room and helps them\nfind each other.\n\n**No currency/item coupling, no per-player cache mirror.** Unlike every other\nmodule in this SDK, no Multiplayer action ever returns a `ResourceOperation`\nor writes to `client.data.user.state` — there is nothing to mirror into a\nlocal player-state cache. Room state itself is ephemeral (Redis, TTL-based, no\nMongo document), so the SDK surface here is pure transport + event-emission:\ncall a method, get a response, emit an event, done. There is no\n`user:multiplayerUpdated` event and no `client.data.user.state.Multiplayer` —\ndon't look for either. The one piece of _config_ this module does expose —\n`MultiplayerDefinitions` (feature switch, topology/chat defaults, room-size\ncap, TTL, payload limits, ICE servers) — rides in on the title's public\nconfig bundle rather than a dedicated fetch call; see\n[Reading limits from config](#reading-limits-from-config).\n\nThis skill is for **using** the production `MultiplayerService`, not for\nporting or extending it. **Don't hand-roll WebRTC** on top of it: the SDK ships\n`RealtimeSession` (`@idosgames/core/realtime`, see\n[Recommended: RealtimeSession](#recommended-realtimesession)) which owns the\n`RTCPeerConnection`s, the poll loop and the signaling etiquette the backend\nexpects. The raw methods below are for lobby UI and for the rare custom\ntransport. Server rejections (room full, wrong join\ncode, not the owner, payload too large) are backend rules — surface them,\ndon't try to pre-validate every one of them client-side.\n\n## A game in an app with the base: `ctx.multiplayer` — do NOT open rooms yourself\n\nIn a project built on the base (lobby) the ROOMS belong to the multiplayer system (the `multiplayer`\nmodule): the list of rooms, \"Create a room\", joining by invite, members, \"Ready\", chat — and the\n`RealtimeSession`, opened already in the lobby. A game never calls `createRoom`/`joinRoom`/\n`RealtimeSession.create|join`; it declares how it plays online and TAKES the room:\n\n```ts\n// module.ts, in setup()\nctx.multiplayer.registerGame({\n mode: \"my-game\", // the game's route id — the room's game\n topology: \"Host\", // \"Host\" (star) | \"Mesh\"\n chatMode: \"P2P\", // the game has its own chat; \"ServerRelay\" = the lobby's chat\n maxPlayers: 8,\n joinInProgress: true, // a player may join a running game (your late-joiner path)\n settings: [\n // the host picks them in \"Create a room\"\n {\n id: \"mode\",\n label: \"Mode\",\n kind: \"choice\",\n default: \"coop\",\n options: [\n { value: \"coop\", label: \"Co-op\" },\n { value: \"pvp\", label: \"PvP\" },\n ],\n },\n { id: \"cheats\", label: \"Cheats for all\", kind: \"toggle\", default: false },\n ],\n // Called on EVERY member's device after the app switched to the game's mode (the host first).\n start: async (room) => {\n const game = await whenGameMounted(); // the scene may mount on this very switch\n game.playOnline(room.session, {\n host: room.isHost && !room.lateJoin, // opens its world for the others\n settings: room.settings, // { mode: \"coop\", cheats: false }\n leave: room.leave, // leave through the lobby, never session.leave()\n });\n },\n});\n```\n\n- The lobby then draws \"With friends\" next to the game's \"Play\" (its rooms), and the rooms screen\n offers the game in \"Create a room\". A game's own \"Play with friends\" button:\n `ctx.features.open(\"multiplayer\", { mode: \"my-game\", create: true })` — from inside the game it\n switches to the lobby by itself.\n- Show that button only while `ctx.features.get(\"multiplayer\")?.available` — without the system the\n game is single-player, nothing else changes.\n- Leave with `room.leave()`: the lobby owns the room and forgets it together with the session.\n- Messages on the session are yours from `start` on; the lobby's own start message goes out before\n any of yours on the ordered channel and never reaches the game.\n- Reference implementation: `modules/voxelcraft` (`module.ts` → `registerGame`, `net/NetGame.ts` →\n `NetGame.fromRoom`, `main.ts` → `Game.acceptRoom`).\n\n### Voice chat — already there, do NOT build your own\n\nA room has voice chat unless the title (`Multiplayer.VoiceEnabled`) or the room's creator turned it\noff. The rooms system owns it completely: the mic modes (off / push-to-talk on `V` / open mic), the\nmic button over the running game, every member's mute and volume, the \"Conversations may be\nrecorded\" notice. Never call `getUserMedia` or add audio tracks yourself. A game only READS\n`room.voice` (a `VoiceController`, or `null` — no voice in this room) for its own touches:\n\n```ts\nstart: (room) => {\n const voice = room.voice;\n if (!voice) return;\n // A \"talking\" marker over a player's avatar.\n voice.on(\"change\", () => avatars.forEach((a) => a.setTalking(voice.isSpeaking(a.userId))));\n // Proximity voice: quieter with distance (0–2, 1 = as is). Call on your own tick.\n for (const p of players) voice.setPeerVolume(p.userId, Math.max(0, 1 - dist(me, p) / 30));\n},\n```\n\n- `voice.isSpeaking(id)` / `voice.speaking()` / `voice.level(id)` (RMS 0–1); `on(\"levels\")` each tick.\n- In a `Host` room every pair is linked for voice, but those guest↔guest links carry NO game data:\n `session.openPeers()` / `broadcast` / `send` still see the star — the game protocol is unchanged.\n\nEverything below — `client.multiplayer` and `RealtimeSession` directly — is for an app WITHOUT the\nbase, or for writing the rooms system itself.\n\n## Mental model\n\n1. One player **creates a room** (picks topology/chat mode/visibility/max\n players, optionally a join code); others **join** by room id (+ join code\n if private). **A player can only be in one room at a time** — creating or\n joining while already in a live room fails; leave first.\n2. Each member **polls** the room on a cursor (`LastSeq`) to receive a stream\n of envelopes: other members' signals, chat, presence changes\n (join/leave/host-migration), and room-closed notices. Polling is also the\n heartbeat — it's what keeps your presence and the room's Redis TTL alive.\n3. To establish a direct connection, members exchange SDP offer/answer and ICE\n candidates by calling **`sendSignal`** — the server just relays the\n `payload` string to a specific `targetUserID`; it never inspects or\n validates WebRTC semantics.\n4. Optional **`sendChat`** (only meaningful when the room's `ChatMode` is\n `ServerRelay` — under `P2P` chat should go over your own DataChannel\n instead) and **`setMemberState`** (ready flags + a free-form string map)\n round out lobby UX.\n5. Someone **leaves**, or the owner/host **closes** the room; on host\n departure the server may migrate to a `NewHostUserID` if members remain.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst mp = client.multiplayer; // the MultiplayerService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args — missing room id/name/target/kind), `\"unauthorized\"`,\n`\"throttled\"` (fired the same endpoint again inside the transport's throttle\nwindow), `\"connection\"` (transient, offer Retry), `\"validation\"`\n(response/schema drift), or `\"server\"` (the backend rejected it — `error`\ncarries a human-readable reason; see the verbatim strings below).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------ |\n| `createRoom(roomName, options?)` | Create a room; caller becomes owner/host. | `RoomSnapshotResponse` |\n| `joinRoom(roomID, joinCode?)` | Join an existing room. | `RoomSnapshotResponse` |\n| `leaveRoom(roomID, { successorUserID?, keepalive? }?)` | Leave a room (may trigger host migration or close it if empty). A leaving host may name the successor; `keepalive` lets the request outlive the page (`pagehide`). | `LeaveRoomResponse` |\n| `listRooms(page?, pageSize?)` | Page through public rooms (default `page=0, pageSize=20`, server caps `pageSize` at 50). | `RoomsPageResponse` |\n| `getRoom(roomID)` | Fetch a fresh snapshot of one room. | `RoomSnapshotResponse` |\n| `closeRoom(roomID)` | Owner/host-only: force-close a room. | `LeaveRoomResponse` |\n| `poll(roomID, lastSeq)` | Long-poll cursor: get every envelope with `Seq > lastSeq`. | `PollResponse` |\n| `sendSignal(roomID, targetUserID, signalKind, payload)` | Relay an SDP offer/answer/ICE candidate to one member. | `PublishResponse` |\n| `sendChat(roomID, chatText)` | Server-relayed chat message (only when `ChatMode: ServerRelay`). | `PublishResponse` |\n| `setMemberState(roomID, ready?, memberState?)` | Publish this member's ready flag / free-form lobby state. | `PublishResponse` |\n| `claimHost(roomID)` | Take the host role from a host that stopped polling (silent longer than `MemberTimeoutMs`). The silent host is evicted; `PlayerLeft` (Kind `\"timeout\"`) + `HostChanged` go to everyone. Exactly one claimant wins. | `RoomSnapshotResponse` |\n| `kickMember(roomID, targetUserID, ban?)` | Owner/host-only: remove a member (`PlayerLeft`, Kind `\"kicked\"`); `ban` also puts them on the room's ban list (survives host changes, dies with the room). Their TURN credentials are revoked. | `KickMemberResponse` (`Removed`, `Banned`) |\n| `unbanMember(roomID, targetUserID)` | Owner/host-only: take a player off the ban list. | `UnbanMemberResponse` (`WasBanned`) |\n\n`createRoom`'s `options`: `{ topology?: MultiplayerTopology (\"Mesh\"|\"Host\"),\nchatMode?: MultiplayerChatMode (\"ServerRelay\"|\"P2P\"), maxPlayers?: number,\nvisibility?: RoomVisibility (\"Public\"|\"Private\"), joinCode?: string }`. Any\nfield you omit falls back to the title's configured defaults\n(`MultiplayerDefinitions.DefaultTopology`/`DefaultChatMode`), and an override\nis only honored if the title allows it\n(`AllowCreatorTopologyOverride`/`AllowCreatorChatOverride`) — otherwise the\ncall fails server-side with `\"Topology override is not allowed for this\ntitle.\"` / `\"Chat mode override is not allowed for this title.\"`.\n`maxPlayers` is clamped down to the title's `MaxPlayersPerRoom` hard cap; you\ncan only ask for _fewer_, never more.\n\n`closeRoom` succeeds for either the room's `OwnerUserID` **or** its current\n`HostUserID` (these can diverge after a host migration) — anyone else gets\n`\"Only the room owner or host can close the room.\"`.\n\nEvery method still emits its event on success even though nothing is cached —\nevents are your only hook here, there's no cache to read afterward.\n\n### Server error strings (verbatim)\n\nThese are exact `error` text from the backend (`RoomService.cs` /\n`SignalingService.cs`) — match on substrings if you need to branch, but the\nsafe default is just to display `result.error` as-is:\n\n- `\"Multiplayer is disabled for this title.\"` — title-wide kill switch is off.\n- `\"You are already in room {roomId}. Leave it first.\"` — one-active-room rule,\n on both `createRoom` and `joinRoom`.\n- `\"RoomID is required.\"` / `\"TargetUserID is required.\"` / `\"SignalKind is\nrequired.\"` — same validation the SDK does client-side; you'll only see\n these from a raw/bypassed call.\n- `\"Invalid or missing join code.\"` — private room join code mismatch.\n- `\"Room not found.\"` — unknown room id, or a **private** room you aren't a\n member of (privacy is enforced by returning the same not-found error, not a\n distinct \"forbidden\").\n- `\"Room is closed.\"` / `\"Room is full.\"` — join-time rejections.\n- `\"Only the room owner or host can close the room.\"`\n- `\"You were removed from this room.\"` — `joinRoom` by a banned player.\n- `\"The host is still active.\"` — `claimHost` while the host still polls\n (retry later; only a host silent for `MemberTimeoutMs` can be replaced).\n- `\"Only the room owner or host can remove players.\"` / `\"Cannot kick\nyourself.\"` / `\"Cannot remove the host.\"` / `\"Cannot remove the room owner.\"`\n — `kickMember` / `unbanMember`.\n- `\"Room no longer exists.\"` / `\"You are not a member of this room.\"` — from\n `poll`, `sendSignal`, `sendChat`, `setMemberState` once you've dropped out of\n the member hash (TTL expiry, or someone else removed you).\n- `\"Target user is not in this room.\"` — `sendSignal` to a non-member.\n- `\"Cannot signal yourself.\"`\n- `` `Signal payload exceeds {N} bytes.` `` — `MaxSignalPayloadBytes` (default 16384) exceeded.\n- `` `Chat text exceeds {N} characters.` `` — `MaxChatTextLength` (default 500)\n exceeded.\n- `\"Chat is peer-to-peer in this room; send it over the DataChannel.\"` —\n called `sendChat` while `ChatMode` is `P2P`.\n- `\"Corrupted member record.\"` — internal deserialization failure on\n `setMemberState`; treat as transient/report.\n\n## Reading limits from config\n\nThere's no `getMultiplayerDefinitions()` call — `MultiplayerDefinitions`\ntravels as part of the title's public config bundle, not a per-module\ndefinitions fetch. It's populated by `client.title.getTitlePublicConfiguration()`\n(or already present after login, since the bootstrap `ClientState` includes\nthe title config):\n\n```ts\nimport type { MultiplayerDefinitions } from \"@idosgames/core\";\n\nawait client.title.getTitlePublicConfiguration(); // if not already loaded\nconst cfg = client.data.config.titlePublicConfiguration?.Multiplayer as\n MultiplayerDefinitions | undefined;\n\ncfg?.MaxPlayersPerRoom; // hard cap enforced server-side\ncfg?.MaxChatTextLength;\ncfg?.MaxSignalPayloadBytes;\ncfg?.HeartbeatIntervalMs; // recommended poll cadence, not enforced\ncfg?.MemberTimeoutMs; // client-side presumed-offline threshold\n```\n\nRead it for building config-driven UI (create-room forms that pre-fill/clamp\n`maxPlayers`, a chat box that disables itself when `ChatMode` will be `P2P`,\netc.) — the server enforces the real limits regardless of what you show.\n\n## Events\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn. There is no\ncoarse `user:*Updated`/`user:anyUpdated` companion for any of these — they\ndon't touch the shared cache.\n\n- `multiplayer:roomCreated` → `RoomSnapshotResponse`\n- `multiplayer:roomJoined` → `RoomSnapshotResponse`\n- `multiplayer:roomLeft` → `LeaveRoomResponse`\n- `multiplayer:roomsListed` → `RoomsPageResponse`\n- `multiplayer:roomLoaded` → `RoomSnapshotResponse`\n- `multiplayer:roomClosed` → `LeaveRoomResponse`\n- `multiplayer:polled` → `PollResponse`\n- `multiplayer:signalSent` → `PublishResponse`\n- `multiplayer:chatSent` → `PublishResponse`\n- `multiplayer:memberStateSet` → `PublishResponse`\n- `multiplayer:hostClaimed` → `RoomSnapshotResponse`\n- `multiplayer:memberKicked` → `KickMemberResponse`\n- `multiplayer:memberUnbanned` → `UnbanMemberResponse`\n\n```ts\nconst off = client.on(\"multiplayer:roomClosed\", (r) => {\n if (r.Closed) navigateToLobbyList();\n});\n// later: off();\n```\n\nFor the full shape of `RoomSnapshotResponse`, `RealtimeEnvelope`,\n`RoomMemberView`, and `MultiplayerDefinitions`, see\n[references/data-model.md](references/data-model.md).\n\n## Recommended: RealtimeSession\n\n`@idosgames/core/realtime` is the WebRTC transport on top of this module. Use it\ninstead of writing the offer/answer/ICE dance yourself:\n\n```ts\nimport { RealtimeSession } from \"@idosgames/core/realtime\";\n\nconst res = await RealtimeSession.create(client, \"My world\", {\n topology: \"Host\", // star: guests link to the host only\n visibility: \"Public\",\n maxPlayers: 8,\n});\n// guests: await RealtimeSession.join(client, roomID, joinCode?)\nif (!res.ok) return showError(res.error);\nconst session = res.data;\n\nsession.on(\"peerOpen\", ({ peerId }) => sendWorldSnapshot(peerId));\nsession.on(\"message\", ({ from, channel, data }) => handle(from, channel, data));\nsession.on(\"hostChanged\", ({ hostId }) => {\n if (hostId === session.selfId) becomeHost();\n});\nsession.on(\"removed\", ({ reason }) => backToMenu(reason)); // kicked / banned\nsession.on(\"roomClosed\", () => backToMenu(\"closed\"));\n\nsession.send(peerId, \"rel\", bytes); // ordered + reliable, fragmented into 16 KB frames\nsession.broadcast(\"fast\", poseBytes); // unordered, no retransmits: positions etc.\n```\n\nWhat it does for you:\n\n- **Two DataChannels per link**: `rel` (ordered, reliable; large messages are\n fragmented) and `fast` (unordered, `maxRetransmits: 0`). `send` returns\n `false` when the link is not open, a `fast` message is too big, or `rel` is\n backed up — drop or retry, don't queue forever.\n- **Cheap polling**: 1 s only while something is being set up, then the host\n polls every 2.5 s (to see newcomers) and everyone else every 20 s (just to\n stay a member). Signals go through a single-flight, per-peer batched outbox,\n and descriptions carry their ICE candidates — about one `SendSignal` per\n side per connection.\n- **Liveness**: own keepalive on `fast` (a silent peer is closed after ~6 s),\n RTT via `linkStats(peerId)` (measured every ~5 s even under load),\n reconnect with back-off.\n- **Host handover**: the host calls `setSuccessor(userId)`; `leave()` (and\n page close — a `keepalive` request on `pagehide`) passes it to the server.\n If the host tab dies instead, the successor calls `session.claimHost()`\n once the host has been silent for the title's `MemberTimeoutMs`.\n `setExtraPeers([...])` keeps standby links (e.g. every guest to the\n successor) so the handover does not wait for signaling.\n- **Moderation**: `session.kick(userId, ban?)`, `session.unban(userId)`. After\n a successful kick the host sends the kicked player a \"check your membership\"\n notice over the link; that player polls at once and gets `removed` within\n about a round trip. The notice is only a hint — the SERVER confirms the\n removal, so a forged notice costs one extra poll and nothing else.\n- **TURN**: `IceServers` of the snapshot are used as is. When the engine has\n Cloudflare TURN configured (env `CLOUDFLARE_TURN_KEY_ID` / `CLOUDFLARE_TURN_API_TOKEN` on the\n engine — not title config) members get short-lived TURN credentials in the\n snapshot; otherwise STUN only.\n\nA full game built on it: `modules/voxelcraft/src/net/` (NetGame — dimension\nowners, replicas, host migration with a shadow copy of the world).\n\n## Recipes\n\n### Create or join a room\n\n```ts\nconst created = await client.multiplayer.createRoom(\"Match 42\", {\n topology: \"Mesh\",\n chatMode: \"ServerRelay\",\n maxPlayers: 4,\n visibility: \"Private\",\n joinCode: \"AB12\",\n});\nif (!created.ok) return showError(created.error);\nconst { RoomID, Seq, IceServers, Members } = created.data;\n\n// another player:\nconst joined = await client.multiplayer.joinRoom(RoomID, \"AB12\");\nif (!joined.ok) return showError(joined.error); // e.g. wrong code, room full\n```\n\n`IceServers` on the snapshot is the STUN/TURN config for your `RTCPeerConnection`\n— pass it straight through (if the title has none configured, the backend\nfalls back to public STUN servers, so this array is never empty). TURN\ncredentials, when the engine has Cloudflare TURN configured, are included only\nfor room members and expire after ~12 h — never put a static TURN login into\nthe title's public config.\n`Seq` is your starting poll cursor. Remember the one-active-room rule: a\nsecond `createRoom`/`joinRoom` call while already in a room fails with\n`\"You are already in room {roomId}. Leave it first.\"` — call `leaveRoom` (or\ncheck your own app state) before switching rooms.\n\n### The polling loop (signals, chat, presence)\n\nPoll is a cursor-based long-poll: pass the last `Seq` you've seen, get back\neverything newer, and each response's `MaxSeq` becomes your next cursor. Each\npoll call also renews your presence heartbeat and the room's TTL server-side\n— an idle client that stops polling will eventually look disconnected to\nothers and the room itself can expire.\n\n```ts\nlet lastSeq = created.data.Seq ?? 0;\nlet polling = true;\n\nasync function pollLoop(roomID: string) {\n while (polling) {\n const res = await client.multiplayer.poll(roomID, lastSeq);\n if (!res.ok) {\n if (res.reason === \"connection\") continue; // transient, retry\n break; // unauthorized/server error (\"Room no longer exists.\", etc) — stop and surface it\n }\n for (const env of res.data.Envelopes ?? []) {\n switch (env.Type) {\n case \"Signal\":\n // env.Kind: \"offer\" | \"answer\" | \"candidate\"; env.FromUserID, env.Payload\n handleSignal(env.FromUserID, env.Kind, env.Payload);\n break;\n case \"Chat\":\n appendChatMessage(env.FromUserID, env.Payload);\n break;\n case \"PlayerJoined\":\n case \"PlayerLeft\":\n case \"MemberState\":\n refreshRoomMembers(roomID);\n break;\n case \"HostChanged\":\n break; // env.Payload / a follow-up getRoom() carries new host\n case \"RoomClosed\":\n polling = false;\n break;\n }\n }\n lastSeq = res.data.MaxSeq ?? lastSeq;\n }\n}\n```\n\nThere's no push/websocket transport here — polling cadence is your call. The\ntitle's `MultiplayerDefinitions.HeartbeatIntervalMs` (default 1000ms) is a\n_recommended_ interval only, not enforced — poll too fast and you risk\n`reason: \"throttled\"` at the transport layer; too slow and signaling feels\nlaggy, and other members' `LastSeenMs` will look stale to you. Use the\nrecommended interval as your default; back off on `\"connection\"` errors\nrather than hot-looping.\n\n### Exchange a WebRTC offer/answer\n\n```ts\n// caller side, after creating an RTCPeerConnection and an offer:\nconst offer = await pc.createOffer();\nawait pc.setLocalDescription(offer);\nawait client.multiplayer.sendSignal(\n roomID,\n targetUserID,\n \"offer\",\n JSON.stringify(offer),\n);\n\n// callee side, from the poll loop's \"Signal\"/\"offer\" case:\nconst offer = JSON.parse(env.Payload);\nawait pc.setRemoteDescription(offer);\nconst answer = await pc.createAnswer();\nawait pc.setLocalDescription(answer);\nawait client.multiplayer.sendSignal(\n roomID,\n env.FromUserID,\n \"answer\",\n JSON.stringify(answer),\n);\n\n// both sides, as ICE candidates trickle in:\npc.onicecandidate = (e) => {\n if (e.candidate) {\n client.multiplayer.sendSignal(\n roomID,\n targetUserID,\n \"candidate\",\n JSON.stringify(e.candidate),\n );\n }\n};\n```\n\n`signalKind` is a free-form string (`\"offer\"`/`\"answer\"`/`\"candidate\"` by\nconvention) and `payload` is an opaque string — the server relays both without\ninspecting them, so encoding is entirely up to you (JSON-stringify is the\nobvious choice). Keep the serialized payload under the title's\n`MaxSignalPayloadBytes` (default 16384) — an oversized payload is rejected\nwith `` `Signal payload exceeds {N} bytes.` `` before it's ever relayed, and\nyou can't signal yourself (`\"Cannot signal yourself.\"`) or a user who isn't\ncurrently a room member (`\"Target user is not in this room.\"`).\n\n### Lobby ready-check and leaving\n\n```ts\nawait client.multiplayer.setMemberState(roomID, true, { loadout: \"red\" });\n// other members see this via a \"MemberState\" envelope on their next poll,\n// then re-fetch with getRoom(roomID) to read updated Members[].Ready/State.\n\nconst left = await client.multiplayer.leaveRoom(roomID);\nif (left.ok && left.data.Closed) {\n // room was empty/owner left with no migration target — it's gone\n} else if (left.ok && left.data.NewHostUserID) {\n // host migrated; that member is now authoritative for Host topology\n}\n```\n\n## Gotchas\n\n- **Nothing here touches `client.data.user.state`.** Don't look for a cached\n room state or resource balances changing — read everything from method\n responses and poll envelopes, and keep your own local room/member state in\n your app layer. The one thing that _is_ cached is the title-wide\n `MultiplayerDefinitions` config, and only after you fetch the title config\n (see [Reading limits from config](#reading-limits-from-config)).\n- **One room per player, enforced server-side.** `createRoom` and `joinRoom`\n both reject with `\"You are already in room {roomId}. Leave it first.\"` if\n you're already a live member elsewhere — there's no implicit \"leave old,\n join new.\"\n- **Room state is ephemeral.** No persistent document backs a room — it's\n Redis, TTL-based (`RoomTtlSeconds`, default 3600s, renewed on every\n interaction including `poll`). A room can disappear if nobody interacts\n with it; don't assume a `RoomID` is valid indefinitely. A title may also\n set a hard lifetime (`MaxRoomLifetimeSeconds`, counted from creation, NOT\n renewed by activity): when it passes, members receive `RoomClosed` with\n `Kind: \"expired\"` mid-game — save progress and offer \"new room\".\n- **`sendChat` only works under `ChatMode: ServerRelay`.** Under `P2P` chat\n mode, send chat over your own WebRTC DataChannel instead — calling\n `sendChat` in `P2P` mode fails with `\"Chat is peer-to-peer in this room;\nsend it over the DataChannel.\"` (check the room snapshot's `ChatMode`\n before wiring a chat send button to it).\n- **`closeRoom` works for the owner or the current host, not just the\n original creator.** After a host migration the new host can also close the\n room; anyone else gets `\"Only the room owner or host can close the room.\"`.\n Regular non-host, non-owner members only have `leaveRoom` (which itself may\n close the room if it empties out).\n- **Private rooms 404 instead of 403.** `getRoom`/`poll`/etc. on a private\n room you're not a member of returns the same `\"Room not found.\"` as a\n genuinely unknown id — the backend doesn't leak room existence to\n non-members.\n- **No automatic eviction — except via `claimHost`.** `RoomMemberView.LastSeenMs`\n is a last-heartbeat timestamp for clients to compare against\n `MultiplayerDefinitions.MemberTimeoutMs` themselves (e.g. to gray out a\n presumed-disconnected member). The server never evicts a stale member on\n its own; the one exception is `claimHost`, which removes a HOST that has\n been silent longer than `MemberTimeoutMs` and hands the role to the caller.\n- **Poll cadence is uncapped by the SDK — you own the rate.** There's no\n built-in backoff or interval; hitting the same endpoint faster than the\n transport's throttle window returns `reason: \"throttled\"` rather than\n duplicating work. `HeartbeatIntervalMs` is the title's _recommended_\n interval, not an enforced one. Design your loop's interval deliberately\n rather than polling in a tight `while(true)`.\n- **`Payload`/`signalKind` are opaque strings.** The server does not validate\n WebRTC SDP/ICE structure — malformed payloads only fail on the _receiving_\n client when it tries to parse/apply them, not at the relay. It does,\n however, enforce a byte-size ceiling (`MaxSignalPayloadBytes`) before\n relaying at all.\n- **Guard against double-submit** on room actions (create/join/close): each\n call is a distinct request, so two separate calls are two real operations\n server-side (e.g. two `createRoom` calls can both succeed as two different\n rooms if the first response is slow). Disable the control while a call is\n in flight.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full shape of\n`RoomSnapshotResponse`, `RoomMemberView`, `RealtimeEnvelope`/`EnvelopeType`,\n`MultiplayerDefinitions`, and the request/action surface, plus the room\nlifecycle state machine (Open → Closed, host migration, TTL expiry). Read it\nwhen building richer room/lobby UI or wiring up the poll loop's envelope\nhandling in detail.\n",
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",