@idosgames/mcp 0.1.17 → 0.1.18

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 (32) hide show
  1. package/dist/cli.js +1 -1
  2. package/package.json +1 -1
  3. package/registry/host.json +14 -6
  4. package/registry/index.json +731 -33
  5. package/registry/modules/board-game.json +4 -4
  6. package/registry/modules/character-ui.json +78 -0
  7. package/registry/modules/chat-ui.json +74 -0
  8. package/registry/modules/craft-ui.json +74 -0
  9. package/registry/modules/deal-offer-ui.json +73 -0
  10. package/registry/modules/events-ui.json +80 -0
  11. package/registry/modules/friends-ui.json +73 -0
  12. package/registry/modules/game-hud.json +15 -10
  13. package/registry/modules/idle-rpg.json +4 -4
  14. package/registry/modules/inventory-ui.json +73 -0
  15. package/registry/modules/leaderboard-ui.json +75 -0
  16. package/registry/modules/marketplace-ui.json +75 -0
  17. package/registry/modules/profile-ui.json +74 -0
  18. package/registry/modules/pvp-ui.json +72 -0
  19. package/registry/modules/referral-ui.json +73 -0
  20. package/registry/modules/rewards-ui.json +75 -0
  21. package/registry/modules/season-ui.json +79 -0
  22. package/registry/modules/showcase.json +92 -0
  23. package/registry/modules/store-ui.json +75 -0
  24. package/registry/modules/tutorial-ui.json +71 -0
  25. package/registry/modules/voxelcraft.json +75 -30
  26. package/registry/modules/workshop.json +3 -3
  27. package/registry/skills/idosgames-module-contract.json +1 -1
  28. package/registry/skills/idosgames-ui-kit.json +6 -0
  29. package/registry/skills/leaderboard-system.json +1 -1
  30. package/registry/skills/multiplayer-realtime.json +3 -3
  31. package/registry/skills/referral-system.json +2 -2
  32. package/registry/skills/timed-event-system.json +1 -1
@@ -39,9 +39,9 @@
39
39
  "version": "0.1.0"
40
40
  },
41
41
  "dependencies": {
42
- "@idosgames/core": "0.18.0",
43
- "@idosgames/module-sdk": "0.4.1",
44
- "@idosgames/react": "0.2.11",
42
+ "@idosgames/core": "0.19.0",
43
+ "@idosgames/module-sdk": "0.5.0",
44
+ "@idosgames/react": "0.3.0",
45
45
  "react": "19.2.7"
46
46
  },
47
47
  "contentHash": "01b9b8439ce44320e7ea8a1aa0a679b3773c291c784a5999c9eab7114c80339d",
@@ -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, game-hud, workshop), or asks about defineModule, registerScene/registerPanel/registerRoute, activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic, ctx.content / ContentTypeHandler (letting the Workshop publish and open the game's content), 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, game-hud, workshop), or asks about defineModule, registerScene/registerPanel/registerRoute,\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.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: \"🎲\" }); // nav/mode entry\n },\n});\n```\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\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). 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`).\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, game-hud, workshop), or asks about defineModule, registerScene/registerPanel/registerRoute,\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.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: \"🎲\" }); // nav/mode entry\n },\n});\n```\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\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). 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 game-hud installed the HUD still 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
  }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "idosgames-ui-kit",
3
+ "description": "Build and restyle game screens with the iDosGames UI kit (@idosgames/react/ui) and the ready screen modules (*-ui): change colours, animations and sounds in ONE file (src/ui.config.ts), install a shop / heroes / inventory / leaderboard / rewards / craft / marketplace / offers / events / season / friends / chat / invite / arena / profile / tutorials screen instead of writing it, open another module's screen with ctx.features.open, register a new screen with ctx.features.register, split a dense screen into popups (Popup, ConfirmPopup, bottom sheet on a phone), play UI sounds, celebrate a reward (icons fly to the balance counters), mark elements for tutorial hints (data-tutorial-anchor), and read the player's sound / motion prefs. Use this whenever the user asks to change how the game's interface looks, sounds or animates, to add or build a game screen or menu, a popup, a reward animation, a bottom navigation, a \"More\" menu, a settings screen, or touches @idosgames/react/ui, defineUiConfig, ui.config.ts, ctx.features, FeatureScreenProps, useCelebrate, usePlayerPrefs, Popup, Tabs or ResourceList.",
4
+ "content": "---\nname: idosgames-ui-kit\ndescription: >-\n Build and restyle game screens with the iDosGames UI kit (@idosgames/react/ui) and the ready\n screen modules (*-ui): change colours, animations and sounds in ONE file (src/ui.config.ts),\n install a shop / heroes / inventory / leaderboard / rewards / craft / marketplace / offers /\n events / season / friends / chat / invite / arena / profile / tutorials screen instead of writing\n it, open another module's screen with ctx.features.open, register a new screen with\n ctx.features.register, split a dense screen into popups (Popup, ConfirmPopup, bottom sheet on a\n phone), play UI sounds, celebrate a reward (icons fly to the balance counters), mark elements for\n tutorial hints (data-tutorial-anchor), and read the player's sound / motion prefs. Use this\n whenever the user asks to change how the game's interface looks, sounds or animates, to add or\n build a game screen or menu, a popup, a reward animation, a bottom navigation, a \"More\" menu, a\n settings screen, or touches @idosgames/react/ui, defineUiConfig, ui.config.ts, ctx.features,\n FeatureScreenProps, useCelebrate, usePlayerPrefs, Popup, Tabs or ResourceList.\n---\n\n# The UI kit and the screen modules\n\nThe game's interface is built from **screen modules** on one **UI kit**:\n\n- `@idosgames/react/ui` — the kit: components (buttons, tabs, popups, resource lists, item tiles,\n progress bars, timers), the reward popup, toasts, UI sounds (synthesized, no files), animations,\n and the player's own sound / motion prefs.\n- `*-ui` modules — ready screens of the platform's systems, each a `feature` module:\n\n| Module | Feature id | Screen |\n| ---------------- | ------------- | -------------------------------------------------------------- |\n| `store-ui` | `store` | shop: stores as tabs, offers, purchase popup |\n| `character-ui` | `character` | heroes: gear slots, \"Equip best\", stats, ranks, skins (popups) |\n| `inventory-ui` | `inventory` | items by rarity, item card, lootbox opening |\n| `leaderboard-ui` | `leaderboard` | tables, my place, rank rewards, last cycle's prize |\n| `rewards-ui` | `rewards` | daily calendar, quests, points track |\n| `craft-ui` | `craft` | recipes, contract popup |\n| `marketplace-ui` | `marketplace` | lots, auctions, selling, trades |\n| `deal-offer-ui` | `deals` | special offers, auto-popup of a new deal |\n| `events-ui` | `events` | limited-time events, milestones, VIP prize |\n| `season-ui` | `season` | season tiers, VIP prize |\n| `friends-ui` | `friends` | friends, requests, suggestions, news |\n| `chat-ui` | `chat` | channels, private chats |\n| `referral-ui` | `invite` | own code and link, friend's code, rewards |\n| `pvp-ui` | `pvp` | duels, strategy, fight replay |\n| `profile-ui` | `profile` | name, sound, language, consent, delete account |\n| `tutorial-ui` | `tutorials` | hint overlay + replay list |\n\n**Need one of these? Install the module — don't write the screen.** Every screen follows the\nserver's rules and ships demo title settings (`title-seed.json`).\n\n## Change the look, animations and sounds — only in `src/ui.config.ts`\n\n```ts\nimport { defineUiConfig } from \"@idosgames/react/ui\";\n\nexport default defineUiConfig({\n theme: {\n bgTop: \"#10263f\",\n panel: \"#1b3a5c\",\n gold: \"#ffcc33\",\n radius: 12,\n rarity: { Mythic: \"#ff3df5\" },\n },\n motion: { speed: 1.5, intensity: 0.5, particles: false },\n sound: {\n volume: 0.4,\n events: {\n click: \"tab\",\n countTick: false,\n reward: { url: \"https://…/win.mp3\" },\n },\n },\n});\n```\n\n- Every field is optional; what you leave out keeps its default. The host passes the file to\n `mountHost({ ui })`, and every module on the kit follows it at once.\n- ⚠ **Never restyle a catalog module by editing it.** An edited module is marked \"customized\" and\n stops getting catalog updates. Colours, radii, fonts, animation speed and sounds are all here.\n- Theme tokens: `bgTop`, `bgBottom`, `panel`, `panelDeep`, `panelEdge`, `text`, `textDim`, `gold`,\n `goldDeep`, `green`, `greenDeep`, `blue`, `blueDeep`, `red`, `shadow`, `backdrop`, `radius`,\n `buttonRadius`, `font`, `rarity` (rarity id → frame colour).\n- Motion: `enabled`, `speed` (2 = twice as fast), `intensity` (0 = no bounce), `rewardFlight`,\n `particles`, `stagger`, `respectReducedMotion`.\n- Sound events: `click`, `tab`, `open`, `close`, `reward`, `coin`, `purchase`, `claim`, `error`,\n `notify`, `lootbox`, `levelUp`, `countTick`. Each: another event's name, `{ url }`, `{ synth: {\nnotes: [{ freq, dur, wave }] } }` or `false`.\n- The PLAYER's own sound / volume / \"fewer animations\" live on the device (profile-ui draws them):\n `const [prefs, setPrefs] = usePlayerPrefs()` → `{ muted, volume, reducedMotion }`.\n\n## Features: screens that find each other\n\nA screen module registers a **feature**; whoever draws the menu shows it — the showcase lobby as\ntabs (phone: 4 + \"More\", tablet: more, desktop: a side rail), `game-hud` in its ☰ menu (shared-UI\nrole `\"menu\"`), or the host's floating ☰ when nobody takes the role.\n\n```ts\n// module.ts\nsetup(ctx) {\n ctx.features.register({\n id: \"forge\", label: \"Forge\", icon: \"hammer\", group: \"economy\", order: 60,\n primary: false, // true = a main tab of the lobby\n Screen: ForgeScreen, // (props: FeatureScreenProps) => ReactNode; props.args, props.close()\n });\n // Badge (\"something to claim\"): ctx.features.setBadge(\"forge\", 2)\n}\n```\n\n- Open another module's screen without importing it: `ctx.features.open(\"store\")`,\n `ctx.features.open(\"chat\", { userID })`. Check first with `ctx.features.get(\"store\")` — the\n module may not be installed.\n- Groups: `economy`, `events`, `social`, `profile`. Icons are kit glyph names (`shop`, `hero`,\n `bag`, `trophy`, `gift`, `hammer`, `market`, `offer`, `flame`, `friends`, `chat`, `link`,\n `swords`, `user`, `bulb`, …) or an image URL.\n- Something that must be on screen all the time (a daily auto-popup, a badge watcher, an overlay) is\n a panel: `ctx.registerPanel({ id, slot: \"modal\", activeOnly: false, component })`.\n\n## Building a screen on the kit\n\nKeep the screen a short list; put every detail in a **popup**.\n\n```tsx\nimport {\n Button,\n Popup,\n ResourceList,\n Tabs,\n useCelebrate,\n useToast,\n useUiKit,\n errorText,\n grantedBy,\n panel,\n outlined,\n v,\n} from \"@idosgames/react/ui\";\n\nfunction ForgeScreen(): ReactNode {\n const [open, setOpen] = useState<string | null>(null);\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n {/* rows… onClick={() => setOpen(id)} */}\n {open ? (\n <Popup title=\"Forge\" onClose={() => setOpen(null)} width={420}>\n …\n </Popup>\n ) : null}\n </div>\n );\n}\n```\n\n- `Popup` is a bottom sheet on a phone and a centred window elsewhere (`variant=\"center\"` forces\n centred); it animates in and out and plays open/close sounds. Close from inside with\n `usePopupClose()`. `ConfirmPopup` for yes/no. A popup that opens BY ITSELF (not on a tap) passes\n `queued` — auto-popups then show one at a time instead of stacking.\n- `useCelebrate()(lines)` shows \"You got\" and flies the icons to any element marked\n `data-resource-counter=\"{currencyID}\"` (the lobby and game-hud balances are). Build the lines\n from a response with `grantedBy(data)`; a reward with premium (VIP) bundles needs them added.\n- `useUiKit().play(\"claim\")`, `useUiKit().fx.burst(element)` — a sound and a particle burst.\n- `useToast()(text, \"error\")` for refusals; show `errorText(res.error)`, never re-check server rules.\n- Lines of resources: `grantLines(grant)`, `costLines(cost)`, `bundleLines(bundle)`,\n `canAfford(lines, balances)`, `<ResourceList lines={…} />`. Names and icons: `useCatalog()`.\n- Words: `makeT(EN, RU)` for your strings, `plural(n, \"лот|лота|лотов\")` for counts.\n- ⚠ A root `display: grid` with a wide child (a row of tabs) grows past a phone screen — give it\n `gridTemplateColumns: \"minmax(0, 1fr)\"`.\n\n## Tutorial hints\n\n`tutorial-ui` highlights any element marked `data-tutorial-anchor=\"…\"` — the lobby's tabs are\n`tab:{featureID}`, its profile button `lobby:profile`. Mark the controls of your own screen the same\nway; the publisher points tutorial steps at them in LiveOps (Tutorial → step → AnchorID).\n",
5
+ "references": []
6
+ }
@@ -5,7 +5,7 @@
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) rejects a fixed list of **server-only** source types outright\n with `\"Source '{type}' cannot be called from client.\"`: all\n `Board*` sources (`BoardTileLanding`, `BoardPassStart`, `BoardAttack`,\n `BoardRaid`, `BoardBuild`, `BoardStageComplete`, `BoardSpecialComplete`),\n `QuestComplete`, `StorePurchase`, `MilestoneReward`,\n `LeaderboardRankReward`, `MarketplaceSell`, `MarketplaceBuy`. Those are\n wired into leaderboards automatically by the corresponding server feature\n (GameLoop rolls, Marketplace settlements) — you don't and can't call them\n from the client. Client-safe sources like `DailyLogin`, `ReferralInvite`,\n or `CustomAction` (with your own `Params`) are the ones meant for\n `submitScoreFromSource`.\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 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"
9
9
  }
10
10
  ]
11
11
  }
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "multiplayer-realtime",
3
- "description": "Build realtime peer-to-peer multiplayer in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.multiplayer (MultiplayerService): create/join/leave/close rooms, list public rooms, relay WebRTC SDP offer/answer + ICE candidates between room members, long-poll for signals/chat/presence envelopes, send server-relayed chat, and publish member ready/lobby state. This is a signaling and room-lifecycle relay for WebRTC — not a gameplay/economy module. Use this whenever the user is working in the iDosGames TS SDK or its game templates and wants realtime multiplayer rooms/lobbies, WebRTC signaling, peer-to-peer connection setup, a poll loop for realtime events, in-room chat, or ready-check/lobby-state UIs, or otherwise touches client.multiplayer, MultiplayerService, MultiplayerModels, RoomSnapshotResponse, RealtimeEnvelope, or EnvelopeType — even if they don't name the module explicitly.",
4
- "content": "---\nname: multiplayer-realtime\ndescription: >-\n Build realtime peer-to-peer multiplayer in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.multiplayer\n (MultiplayerService): create/join/leave/close rooms, list public rooms,\n relay WebRTC SDP offer/answer + ICE candidates between room members,\n long-poll for signals/chat/presence envelopes, send server-relayed chat, and\n publish member ready/lobby state. This is a signaling and room-lifecycle\n relay for WebRTC — not a gameplay/economy module. Use this whenever the user\n is working in the iDosGames TS SDK or its game templates and wants realtime\n multiplayer rooms/lobbies, WebRTC signaling, peer-to-peer connection setup,\n a poll loop for realtime events, in-room chat, or ready-check/lobby-state\n UIs, or otherwise touches client.multiplayer, MultiplayerService,\n MultiplayerModels, RoomSnapshotResponse, RealtimeEnvelope, or EnvelopeType —\n even if they don't name the module explicitly.\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, and not for implementing WebRTC itself — you still\nown the `RTCPeerConnection` on each client; this module only relays the\nsignaling payloads between them. 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## 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)` | Leave a room (may trigger host migration or close it if empty). | `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\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- `\"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\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## 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 Google STUN servers, so this array is never empty).\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.\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 server-side member timeout/eviction.** `RoomMemberView.LastSeenMs` is a\n last-heartbeat timestamp for clients to compare against\n `MultiplayerDefinitions.MemberTimeoutMs` themselves (e.g. to gray out a\n presumed-disconnected member) — the server does not auto-evict stale\n members; your poll loop or UI must apply that timeout logic.\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",
3
+ "description": "Build realtime peer-to-peer multiplayer in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.multiplayer (MultiplayerService): create/join/leave/close rooms, list public rooms, relay WebRTC SDP offer/answer + ICE candidates between room members, long-poll for signals/chat/presence envelopes, send server-relayed chat, publish member ready/lobby state, take over a silent host (claimHost), kick/ban members; plus the ready-made WebRTC transport RealtimeSession from @idosgames/core/realtime (DataChannels, adaptive polling, host handover, TURN from the room snapshot). This is a signaling and room-lifecycle relay for WebRTC — not a gameplay/economy module. Use this whenever the user is working in the iDosGames TS SDK or its game templates and wants realtime multiplayer rooms/lobbies, WebRTC signaling, peer-to-peer connection setup, a poll loop for realtime events, in-room chat, or ready-check/lobby-state UIs, or otherwise touches client.multiplayer, MultiplayerService, MultiplayerModels, RoomSnapshotResponse, RealtimeEnvelope, EnvelopeType or RealtimeSession — even if they don't name the module explicitly.",
4
+ "content": "---\nname: multiplayer-realtime\ndescription: >-\n Build realtime peer-to-peer multiplayer in a game on the iDosGames\n TypeScript SDK (@idosgames/core) via client.multiplayer\n (MultiplayerService): create/join/leave/close rooms, list public rooms,\n relay WebRTC SDP offer/answer + ICE candidates between room members,\n long-poll for signals/chat/presence envelopes, send server-relayed chat,\n publish member ready/lobby state, take over a silent host (claimHost),\n kick/ban members; plus the ready-made WebRTC transport\n RealtimeSession from @idosgames/core/realtime (DataChannels, adaptive\n polling, host handover, TURN from the room snapshot). This is a signaling\n and room-lifecycle relay for WebRTC — not a gameplay/economy module. Use this whenever the user\n is working in the iDosGames TS SDK or its game templates and wants realtime\n multiplayer rooms/lobbies, WebRTC signaling, peer-to-peer connection setup,\n a poll loop for realtime events, in-room chat, or ready-check/lobby-state\n UIs, or otherwise touches client.multiplayer, MultiplayerService,\n MultiplayerModels, RoomSnapshotResponse, RealtimeEnvelope, EnvelopeType or\n RealtimeSession — even if they don't name the module explicitly.\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## 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.\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",
8
- "content": "# Multiplayer data model — reference\n\nFull shape of the config (`MultiplayerDefinitions`), the room/member view\nmodels, the realtime envelope union, and the request/action surface. All of\nthese are **strictly typed in the SDK** and exported from `@idosgames/core`.\nThe zod schemas keep `.passthrough()`, so a field the backend adds later still\nround-trips as `unknown` extra data rather than being stripped. Field names\nare PascalCase (straight from the backend JSON).\n\nThere is no player-state shape to document here — Multiplayer has none. Every\nmodel below is either title-wide config or an ephemeral, per-room view.\n\n## Contents\n\n- [Config: MultiplayerDefinitions](#config-multiplayerdefinitions) — what rides in on the title's public config\n- [Room lifecycle state machine](#room-lifecycle-state-machine)\n- [RoomSnapshotResponse](#roomsnapshotresponse)\n- [RoomMemberView](#roommemberview)\n- [RoomListItem](#roomlistitem--roomspageresponse)\n- [RealtimeEnvelope + EnvelopeType](#realtimeenvelope--envelopetype)\n- [LeaveRoomResponse / PublishResponse / PollResponse](#other-responses)\n- [Request + actions](#request--actions)\n\n---\n\n## Config: MultiplayerDefinitions\n\nBackend: `MultiplayerDefinitions.cs`, stored at\n`TitlePublicConfigurationModel.Multiplayer`. Public-only data — no secrets.\nThere is no dedicated `getMultiplayerDefinitions()` fetch; this arrives with\nthe full title config (`client.title.getTitlePublicConfiguration()`, or\nalready present after the login bootstrap). See\n[Reading limits from config](../SKILL.md#reading-limits-from-config) in the\nmain skill.\n\n```ts\ninterface MultiplayerDefinitions {\n SystemState?: { Enabled?: boolean }; // title-wide feature switch\n DefaultTopology?: MultiplayerTopology; // \"Mesh\" | \"Host\"; default \"Mesh\"\n AllowCreatorTopologyOverride?: boolean; // default true\n DefaultChatMode?: MultiplayerChatMode; // \"ServerRelay\" | \"P2P\"; default \"ServerRelay\"\n AllowCreatorChatOverride?: boolean; // default true\n MaxPlayersPerRoom?: number; // hard cap; default 8\n RoomTtlSeconds?: number; // Redis key TTL, renewed on every interaction; default 3600\n HeartbeatIntervalMs?: number; // recommended client poll interval; default 1000 — NOT enforced\n MemberTimeoutMs?: number; // client-side \"presumed offline\" threshold; default 15000 — no server auto-eviction\n MaxSignalPayloadBytes?: number; // default 16384\n MaxChatTextLength?: number; // characters; default 500\n RoomLogCap?: number; // how many broadcast envelopes the room-log tail keeps; default 500\n IceServers?: IceServer[]; // empty/absent -> server falls back to public Google STUN\n}\n\ninterface IceServer {\n Urls?: string; // e.g. \"stun:stun.l.google.com:19302\" or \"turn:turn.example.com:3478\"\n Username?: string; // TURN only\n Credential?: string; // TURN only\n}\n```\n\n`MultiplayerTopology`: `\"Mesh\"` (every player connects to every other —\nsmall rooms) | `\"Host\"` (star topology, everyone connects only to the host).\n\n`MultiplayerChatMode`: `\"ServerRelay\"` (chat broadcast through the room-log,\nworks in lobby and as a fallback) | `\"P2P\"` (server does not process chat at\nall — send it over your own DataChannel).\n\n`RoomVisibility`: `\"Public\"` (appears in `listRooms`) | `\"Private\"` (not\nlisted; join by `RoomID` + join code only).\n\n---\n\n## Room lifecycle state machine\n\nA room's `Status` (surfaced as `RoomSnapshotResponse.Status`) is a plain\nstring, driven by these transitions (backend: `RoomService.cs` +\n`RedisService.Realtime.cs`):\n\n- **created** → `\"Open\"`. `createRoom` sets it; the caller becomes both\n `OwnerUserID` and `HostUserID`.\n- **join** — allowed only while `Open` and under `MaxPlayers`; rejected with\n `\"Room is closed.\"` / `\"Room is full.\"` otherwise. A private room additionally\n requires the join code to match (SHA-256 hash comparison server-side —\n the raw code is never stored).\n- **leave** — if the leaving member was the last one, the room is deleted\n outright (`Closed: true` in the `LeaveRoomResponse`, no `RoomClosed`\n envelope is published — there's nobody left to receive it). If members\n remain and the leaver was the host, a new host is picked from the\n remaining members (`NewHostUserID`) and a `HostChanged` envelope is\n broadcast; the room stays `Open`.\n- **close** (`closeRoom`, owner or current host only) → a `RoomClosed`\n envelope is broadcast _before_ the room is marked `Closed`, so in-flight\n pollers still receive the notice; the room's keys then live out their\n remaining TTL rather than being deleted immediately (so late polls still\n resolve cleanly instead of hitting \"gone\").\n- **idle expiry** — every interaction (`poll`, `sendSignal`, `sendChat`,\n `setMemberState`, join/leave) renews the room's Redis TTL\n (`RoomTtlSeconds`). Stop interacting and the room silently disappears; a\n subsequent `poll` on it fails with `\"Room no longer exists.\"` (internally\n the `\"gone\"` code) rather than describing expiry explicitly.\n\nThere is no separate \"starting\"/\"in-progress\" server-tracked status — once\npeers have exchanged signals and opened their DataChannel, that phase is\nentirely off the server's radar. If your game needs a \"match started\" state,\nmodel it yourself via `setMemberState`'s free-form `MemberState` map or your\nown signaling payloads.\n\n---\n\n## RoomSnapshotResponse\n\nReturned by `createRoom`, `joinRoom`, and `getRoom`.\n\n```ts\ninterface RoomSnapshotResponse {\n RoomID?: string;\n Name?: string;\n HostUserID?: string; // current host; may differ from OwnerUserID after migration\n OwnerUserID?: string; // original creator; fixed for the room's lifetime\n Topology?: MultiplayerTopology;\n ChatMode?: MultiplayerChatMode;\n Visibility?: RoomVisibility;\n Status?: string; // \"Open\" | \"Closed\"\n MaxPlayers?: number;\n Members?: RoomMemberView[]; // ordered by JoinedAt ascending\n IceServers?: IceServer[]; // resolved (title config, or Google STUN fallback)\n Seq?: number; // starting poll cursor for this room\n}\n```\n\n`JoinCode` is deliberately never returned in any response — only its SHA-256\nhash is stored server-side, checked against what `joinRoom` submits.\n\n---\n\n## RoomMemberView\n\nOne room member: static public profile fields plus mutable lobby state.\n\n```ts\ninterface RoomMemberView {\n UserID?: string;\n Username?: string;\n AvatarUrl?: string;\n Level?: number; // snapshotted from the player's public profile at join time\n Power?: number; // snapshotted from the player's public profile at join time\n Ready?: boolean; // mutated via setMemberState\n State?: Record<string, string>; // free-form lobby metadata, mutated via setMemberState\n JoinedAt?: number; // unix ms\n IsHost?: boolean; // computed on read: UserID === room's current HostUserID\n LastSeenMs?: number; // unix ms of last heartbeat (poll); compare against MemberTimeoutMs yourself\n}\n```\n\n`Level`/`Power` are a snapshot taken from the player's public profile\n(`UserPublicDataModel`) at the moment they joined — they do **not** live-update\nif the player's Character Power changes mid-room. Re-`joinRoom` (or a fresh\n`getRoom`) is the only way to refresh them; there's no push for profile\nchanges into an existing member record.\n\n`IsHost` and `LastSeenMs` are computed server-side on every read (from\n`hostUserId` in room meta and the presence Sorted Set respectively) — they\nare not stored on the member record itself.\n\n---\n\n## RoomListItem + RoomsPageResponse\n\nReturned by `listRooms`. A deliberately thin projection — no `Members`, no\njoin code, no `Seq` — for cheap listing pages.\n\n```ts\ninterface RoomListItem {\n RoomID?: string;\n Name?: string;\n HostUserID?: string;\n Topology?: MultiplayerTopology;\n ChatMode?: MultiplayerChatMode;\n MaxPlayers?: number;\n MemberCount?: number;\n CreatedAt?: number; // unix ms\n}\n\ninterface RoomsPageResponse {\n Rooms?: RoomListItem[];\n Page?: number;\n PageSize?: number; // server-echoed; clamped to <= 50 regardless of what you requested\n HasMore?: boolean;\n}\n```\n\nOnly `Public`-visibility, currently-`Open` rooms ever appear here — private\nrooms and closed rooms are excluded at the index level, not filtered\nclient-side.\n\n---\n\n## RealtimeEnvelope + EnvelopeType\n\nThe unified delivery item returned by `poll`. `Seq` is a per-room, strictly\nincreasing counter shared by both the broadcast log and every member's\ndirected inbox — that's what makes a single `lastSeq` cursor sufficient to\ndrain all of it.\n\n```ts\ntype EnvelopeType =\n | \"Signal\" // directed: SDP offer/answer or ICE candidate\n | \"Chat\" // broadcast: ChatMode=ServerRelay message\n | \"PlayerJoined\" // broadcast: someone joined\n | \"PlayerLeft\" // broadcast: someone left\n | \"HostChanged\" // broadcast: host migrated\n | \"RoomClosed\" // broadcast: room closed by owner/host\n | \"MemberState\"; // broadcast: someone's Ready/State changed\n\ninterface RealtimeEnvelope {\n Seq?: number;\n Type?: EnvelopeType;\n FromUserID?: string;\n ToUserID?: string; // present only on directed \"Signal\" envelopes\n Kind?: string; // \"Signal\" only: \"offer\" | \"answer\" | \"candidate\" (free-form, by convention)\n Payload?: string; // opaque string; meaning depends on Type (see below)\n Ts?: number; // unix ms, server-assigned\n}\n```\n\n`Payload` by `Type`:\n\n- `\"Signal\"` → whatever string you passed to `sendSignal` (typically\n JSON-stringified `RTCSessionDescriptionInit` or `RTCIceCandidateInit`).\n- `\"Chat\"` → the raw chat text from `sendChat`.\n- `\"PlayerJoined\"` / `\"PlayerLeft\"` → the affected user's `UserID` (also\n duplicated in `FromUserID`).\n- `\"HostChanged\"` → the new host's `UserID`.\n- `\"RoomClosed\"` → the `RoomID` that was closed.\n- `\"MemberState\"` → a JSON string of `{ Ready, State }` — the changed\n member's new ready flag and state map (parse it, or just treat the\n envelope as a signal to re-`getRoom`).\n\nEnvelopes are delivered **at most once** per `poll` call (they're consumed by\nbeing read above `lastSeq`, not re-sent), but nothing deduplicates _across_\noverlapping poll calls if you mismanage your cursor — always advance\n`lastSeq` to the response's `MaxSeq`, never roll it back.\n\n---\n\n## Other responses\n\n```ts\ninterface LeaveRoomResponse {\n Closed?: boolean; // true if the room was closed/removed as a result\n NewHostUserID?: string; // set only when the host left and members remain\n}\n\ninterface PollResponse {\n Envelopes?: RealtimeEnvelope[]; // ordered by Seq ascending\n MaxSeq?: number; // max Seq among returned envelopes, or the sent LastSeq if none — always your next cursor\n}\n\ninterface PublishResponse {\n Seq?: number; // the seq assigned to the envelope you just published, for tracing/debugging\n}\n```\n\n---\n\n## Request + actions\n\nEvery call funnels through one `MultiplayerRequest` shape and a 10-value\n`MultiplayerAction` enum (`CreateRoom`, `JoinRoom`, `LeaveRoom`, `ListRooms`,\n`GetRoom`, `CloseRoom`, `Poll`, `SendSignal`, `SendChat`, `SetMemberState`) —\nyou never construct either yourself; `MultiplayerService` builds the request\nand picks the action per method. Listed here only so error messages and field\nnames in server logs/network tabs are recognizable:\n\n```ts\ninterface MultiplayerRequest {\n RoomID?: string;\n RoomName?: string; // CreateRoom\n Topology?: MultiplayerTopology; // CreateRoom\n ChatMode?: MultiplayerChatMode; // CreateRoom\n MaxPlayers?: number; // CreateRoom\n Visibility?: RoomVisibility; // CreateRoom\n JoinCode?: string; // CreateRoom (sets it) / JoinRoom (submits it)\n TargetUserID?: string; // SendSignal\n SignalKind?: string; // SendSignal: \"offer\" | \"answer\" | \"candidate\"\n Payload?: string; // SendSignal\n ChatText?: string; // SendChat\n Ready?: boolean; // SetMemberState\n MemberState?: Record<string, string>; // SetMemberState\n LastSeq?: number; // Poll cursor\n Page?: number; // ListRooms\n PageSize?: number; // ListRooms\n}\n```\n\nPer-action rate limiting (server-side, not SDK-visible as distinct reasons —\nall surface as `reason: \"throttled\"` from the transport, or a plain\n`\"server\"` failure if you exceed the backend's own request lock): `Poll`,\n`SendSignal`, `SendChat`, and `SetMemberState` are treated as high-frequency\nrealtime traffic with a short per-user lock; `ListRooms`/`GetRoom` are\nread-only with a medium lock; `CreateRoom`/`JoinRoom`/`LeaveRoom`/`CloseRoom`\n(room lifecycle mutations) use the strictest lock. You don't need the exact\nnumbers to use the SDK correctly — just don't hot-loop any of these calls,\nand expect lifecycle actions (create/join/leave/close) to tolerate less\nback-to-back traffic than the realtime loop (poll/signal/chat/state).\n"
8
+ "content": "# Multiplayer data model — reference\n\nFull shape of the config (`MultiplayerDefinitions`), the room/member view\nmodels, the realtime envelope union, and the request/action surface. All of\nthese are **strictly typed in the SDK** and exported from `@idosgames/core`.\nThe zod schemas keep `.passthrough()`, so a field the backend adds later still\nround-trips as `unknown` extra data rather than being stripped. Field names\nare PascalCase (straight from the backend JSON).\n\nThere is no player-state shape to document here — Multiplayer has none. Every\nmodel below is either title-wide config or an ephemeral, per-room view.\n\n## Contents\n\n- [Config: MultiplayerDefinitions](#config-multiplayerdefinitions) — what rides in on the title's public config\n- [Room lifecycle state machine](#room-lifecycle-state-machine)\n- [RoomSnapshotResponse](#roomsnapshotresponse)\n- [RoomMemberView](#roommemberview)\n- [RoomListItem](#roomlistitem--roomspageresponse)\n- [RealtimeEnvelope + EnvelopeType](#realtimeenvelope--envelopetype)\n- [LeaveRoomResponse / PublishResponse / PollResponse](#other-responses)\n- [Request + actions](#request--actions)\n\n---\n\n## Config: MultiplayerDefinitions\n\nBackend: `MultiplayerDefinitions.cs`, stored at\n`TitlePublicConfigurationModel.Multiplayer`. Public-only data — no secrets.\nThere is no dedicated `getMultiplayerDefinitions()` fetch; this arrives with\nthe full title config (`client.title.getTitlePublicConfiguration()`, or\nalready present after the login bootstrap). See\n[Reading limits from config](../SKILL.md#reading-limits-from-config) in the\nmain skill.\n\n```ts\ninterface MultiplayerDefinitions {\n SystemState?: { Enabled?: boolean }; // title-wide feature switch\n DefaultTopology?: MultiplayerTopology; // \"Mesh\" | \"Host\"; default \"Mesh\"\n AllowCreatorTopologyOverride?: boolean; // default true\n DefaultChatMode?: MultiplayerChatMode; // \"ServerRelay\" | \"P2P\"; default \"ServerRelay\"\n AllowCreatorChatOverride?: boolean; // default true\n MaxPlayersPerRoom?: number; // hard cap; default 8\n RoomTtlSeconds?: number; // Redis key TTL, renewed on every interaction; default 3600\n HeartbeatIntervalMs?: number; // recommended client poll interval; default 1000 — NOT enforced\n MemberTimeoutMs?: number; // \"presumed offline\" threshold; default 15000. The server evicts nobody on its own — except a HOST silent this long, via claimHost\n MaxSignalPayloadBytes?: number; // default 16384\n MaxChatTextLength?: number; // characters; default 500\n RoomLogCap?: number; // how many broadcast envelopes the room-log tail keeps; default 500\n IceServers?: IceServer[]; // STUN only here (public config!); empty/absent -> public STUN fallback. TURN comes per member from the engine (Cloudflare), never from this list\n}\n\ninterface IceServer {\n Urls?: string; // e.g. \"stun:stun.l.google.com:19302\" or \"turn:turn.example.com:3478\"\n Username?: string; // TURN only\n Credential?: string; // TURN only\n}\n```\n\n`MultiplayerTopology`: `\"Mesh\"` (every player connects to every other —\nsmall rooms) | `\"Host\"` (star topology, everyone connects only to the host).\n\n`MultiplayerChatMode`: `\"ServerRelay\"` (chat broadcast through the room-log,\nworks in lobby and as a fallback) | `\"P2P\"` (server does not process chat at\nall — send it over your own DataChannel).\n\n`RoomVisibility`: `\"Public\"` (appears in `listRooms`) | `\"Private\"` (not\nlisted; join by `RoomID` + join code only).\n\n---\n\n## Room lifecycle state machine\n\nA room's `Status` (surfaced as `RoomSnapshotResponse.Status`) is a plain\nstring, driven by these transitions (backend: `RoomService.cs` +\n`RedisService.Realtime.cs`):\n\n- **created** → `\"Open\"`. `createRoom` sets it; the caller becomes both\n `OwnerUserID` and `HostUserID`.\n- **join** — allowed only while `Open` and under `MaxPlayers`; rejected with\n `\"Room is closed.\"` / `\"Room is full.\"` otherwise. A private room additionally\n requires the join code to match (SHA-256 hash comparison server-side —\n the raw code is never stored).\n- **leave** — if the leaving member was the last one, the room is deleted\n outright (`Closed: true` in the `LeaveRoomResponse`, no `RoomClosed`\n envelope is published — there's nobody left to receive it). If members\n remain and the leaver was the host, a new host is picked from the\n remaining members (`NewHostUserID`) and a `HostChanged` envelope is\n broadcast; the room stays `Open`.\n- **leave with a successor** — a leaving host may pass `successorUserID`; if\n that player is still a member they become host (otherwise the server picks\n one). `RealtimeSession` does this on `leave()` and on page close.\n- **claimHost** — a member takes the host role when the host has not polled\n for `MemberTimeoutMs`: the silent host is removed, `PlayerLeft` (Kind\n `\"timeout\"`) and `HostChanged` are broadcast. A compare-and-set in Redis\n guarantees one winner; a still-polling host → `\"The host is still active.\"`.\n- **kick / ban** (`kickMember`, owner or current host; never the host or the\n owner themselves) — `PlayerLeft` (Kind `\"kicked\"`). With `ban` the player is\n added to the room's ban list, so `joinRoom` answers `\"You were removed from\nthis room.\"`. The list survives host changes and dies with the room;\n `unbanMember` removes an entry.\n- **close** (`closeRoom`, owner or current host only) → a `RoomClosed`\n envelope is broadcast _before_ the room is marked `Closed`, so in-flight\n pollers still receive the notice; the room's keys then live out their\n remaining TTL rather than being deleted immediately (so late polls still\n resolve cleanly instead of hitting \"gone\").\n- **idle expiry** — every interaction (`poll`, `sendSignal`, `sendChat`,\n `setMemberState`, join/leave) renews the room's Redis TTL\n (`RoomTtlSeconds`). Stop interacting and the room silently disappears; a\n subsequent `poll` on it fails with `\"Room no longer exists.\"` (internally\n the `\"gone\"` code) rather than describing expiry explicitly.\n\nThere is no separate \"starting\"/\"in-progress\" server-tracked status — once\npeers have exchanged signals and opened their DataChannel, that phase is\nentirely off the server's radar. If your game needs a \"match started\" state,\nmodel it yourself via `setMemberState`'s free-form `MemberState` map or your\nown signaling payloads.\n\n---\n\n## RoomSnapshotResponse\n\nReturned by `createRoom`, `joinRoom`, and `getRoom`.\n\n```ts\ninterface RoomSnapshotResponse {\n RoomID?: string;\n Name?: string;\n HostUserID?: string; // current host; may differ from OwnerUserID after migration\n OwnerUserID?: string; // original creator; fixed for the room's lifetime\n Topology?: MultiplayerTopology;\n ChatMode?: MultiplayerChatMode;\n Visibility?: RoomVisibility;\n Status?: string; // \"Open\" | \"Closed\"\n MaxPlayers?: number;\n Members?: RoomMemberView[]; // ordered by JoinedAt ascending\n IceServers?: IceServer[]; // resolved: title STUN (or public fallback) + per-member TURN credentials when the engine has Cloudflare TURN\n Seq?: number; // starting poll cursor for this room\n BannedUserIDs?: string[]; // the ban list — only in the host's / owner's snapshot\n}\n```\n\n`JoinCode` is deliberately never returned in any response — only its SHA-256\nhash is stored server-side, checked against what `joinRoom` submits.\n\n---\n\n## RoomMemberView\n\nOne room member: static public profile fields plus mutable lobby state.\n\n```ts\ninterface RoomMemberView {\n UserID?: string;\n Username?: string;\n AvatarUrl?: string;\n Level?: number; // snapshotted from the player's public profile at join time\n Power?: number; // snapshotted from the player's public profile at join time\n Ready?: boolean; // mutated via setMemberState\n State?: Record<string, string>; // free-form lobby metadata, mutated via setMemberState\n JoinedAt?: number; // unix ms\n IsHost?: boolean; // computed on read: UserID === room's current HostUserID\n LastSeenMs?: number; // unix ms of last heartbeat (poll); compare against MemberTimeoutMs yourself\n}\n```\n\n`Level`/`Power` are a snapshot taken from the player's public profile\n(`UserPublicDataModel`) at the moment they joined — they do **not** live-update\nif the player's Character Power changes mid-room. Re-`joinRoom` (or a fresh\n`getRoom`) is the only way to refresh them; there's no push for profile\nchanges into an existing member record.\n\n`IsHost` and `LastSeenMs` are computed server-side on every read (from\n`hostUserId` in room meta and the presence Sorted Set respectively) — they\nare not stored on the member record itself.\n\n---\n\n## RoomListItem + RoomsPageResponse\n\nReturned by `listRooms`. A deliberately thin projection — no `Members`, no\njoin code, no `Seq` — for cheap listing pages.\n\n```ts\ninterface RoomListItem {\n RoomID?: string;\n Name?: string;\n HostUserID?: string;\n Topology?: MultiplayerTopology;\n ChatMode?: MultiplayerChatMode;\n MaxPlayers?: number;\n MemberCount?: number;\n CreatedAt?: number; // unix ms\n}\n\ninterface RoomsPageResponse {\n Rooms?: RoomListItem[];\n Page?: number;\n PageSize?: number; // server-echoed; clamped to <= 50 regardless of what you requested\n HasMore?: boolean;\n}\n```\n\nOnly `Public`-visibility, currently-`Open` rooms ever appear here — private\nrooms and closed rooms are excluded at the index level, not filtered\nclient-side.\n\n---\n\n## RealtimeEnvelope + EnvelopeType\n\nThe unified delivery item returned by `poll`. `Seq` is a per-room, strictly\nincreasing counter shared by both the broadcast log and every member's\ndirected inbox — that's what makes a single `lastSeq` cursor sufficient to\ndrain all of it.\n\n```ts\ntype EnvelopeType =\n | \"Signal\" // directed: SDP offer/answer or ICE candidate\n | \"Chat\" // broadcast: ChatMode=ServerRelay message\n | \"PlayerJoined\" // broadcast: someone joined\n | \"PlayerLeft\" // broadcast: someone left\n | \"HostChanged\" // broadcast: host migrated\n | \"RoomClosed\" // broadcast: room closed by owner/host\n | \"MemberState\"; // broadcast: someone's Ready/State changed\n\ninterface RealtimeEnvelope {\n Seq?: number;\n Type?: EnvelopeType;\n FromUserID?: string;\n ToUserID?: string; // present only on directed \"Signal\" envelopes\n Kind?: string; // \"Signal\": free-form (\"offer\" | \"answer\" | \"candidate\" by convention; RealtimeSession uses \"rtc\");\n // \"PlayerLeft\": \"kicked\" (FromUserID = who kicked, Payload = kicked player) or\n // \"timeout\" (host replaced via claimHost); absent for a plain leave\n Payload?: string; // opaque string; meaning depends on Type (see below)\n Ts?: number; // unix ms, server-assigned\n}\n```\n\n`Payload` by `Type`:\n\n- `\"Signal\"` → whatever string you passed to `sendSignal` (typically\n JSON-stringified `RTCSessionDescriptionInit` or `RTCIceCandidateInit`).\n- `\"Chat\"` → the raw chat text from `sendChat`.\n- `\"PlayerJoined\"` / `\"PlayerLeft\"` → the affected user's `UserID` (also\n duplicated in `FromUserID`).\n- `\"HostChanged\"` → the new host's `UserID`.\n- `\"RoomClosed\"` → the `RoomID` that was closed.\n- `\"MemberState\"` → a JSON string of `{ Ready, State }` — the changed\n member's new ready flag and state map (parse it, or just treat the\n envelope as a signal to re-`getRoom`).\n\nEnvelopes are delivered **at most once** per `poll` call (they're consumed by\nbeing read above `lastSeq`, not re-sent), but nothing deduplicates _across_\noverlapping poll calls if you mismanage your cursor — always advance\n`lastSeq` to the response's `MaxSeq`, never roll it back.\n\n---\n\n## Other responses\n\n```ts\ninterface KickMemberResponse {\n Removed?: boolean; // the player was a member and has been removed\n Banned?: boolean; // the player is now on the ban list\n}\n\ninterface UnbanMemberResponse {\n WasBanned?: boolean;\n}\n\ninterface LeaveRoomResponse {\n Closed?: boolean; // true if the room was closed/removed as a result\n NewHostUserID?: string; // set only when the host left and members remain\n}\n\ninterface PollResponse {\n Envelopes?: RealtimeEnvelope[]; // ordered by Seq ascending\n MaxSeq?: number; // max Seq among returned envelopes, or the sent LastSeq if none — always your next cursor\n}\n\ninterface PublishResponse {\n Seq?: number; // the seq assigned to the envelope you just published, for tracing/debugging\n}\n```\n\n---\n\n## Request + actions\n\nEvery call funnels through one `MultiplayerRequest` shape and a 13-value\n`MultiplayerAction` enum (`CreateRoom`, `JoinRoom`, `LeaveRoom`, `ListRooms`,\n`GetRoom`, `CloseRoom`, `Poll`, `SendSignal`, `SendChat`, `SetMemberState`,\n`ClaimHost`, `KickMember`, `UnbanMember`) —\nyou never construct either yourself; `MultiplayerService` builds the request\nand picks the action per method. Listed here only so error messages and field\nnames in server logs/network tabs are recognizable:\n\n```ts\ninterface MultiplayerRequest {\n RoomID?: string;\n RoomName?: string; // CreateRoom\n Topology?: MultiplayerTopology; // CreateRoom\n ChatMode?: MultiplayerChatMode; // CreateRoom\n MaxPlayers?: number; // CreateRoom\n Visibility?: RoomVisibility; // CreateRoom\n JoinCode?: string; // CreateRoom (sets it) / JoinRoom (submits it)\n TargetUserID?: string; // SendSignal / KickMember / UnbanMember\n SuccessorUserID?: string; // LeaveRoom: the host names who takes over\n Ban?: boolean; // KickMember: also ban\n SignalKind?: string; // SendSignal: \"offer\" | \"answer\" | \"candidate\"\n Payload?: string; // SendSignal\n ChatText?: string; // SendChat\n Ready?: boolean; // SetMemberState\n MemberState?: Record<string, string>; // SetMemberState\n LastSeq?: number; // Poll cursor\n Page?: number; // ListRooms\n PageSize?: number; // ListRooms\n}\n```\n\nPer-action rate limiting (server-side, not SDK-visible as distinct reasons —\nall surface as `reason: \"throttled\"` from the transport, or a plain\n`\"server\"` failure if you exceed the backend's own request lock): `Poll`,\n`SendSignal`, `SendChat`, and `SetMemberState` are treated as high-frequency\nrealtime traffic with a short per-user lock; `ListRooms`/`GetRoom` are\nread-only with a medium lock; `CreateRoom`/`JoinRoom`/`LeaveRoom`/`CloseRoom`\n(room lifecycle mutations) use the strictest lock. You don't need the exact\nnumbers to use the SDK correctly — just don't hot-loop any of these calls,\nand expect lifecycle actions (create/join/leave/close) to tolerate less\nback-to-back traffic than the realtime loop (poll/signal/chat/state).\n"
9
9
  }
10
10
  ]
11
11
  }