@idosgames/mcp 0.1.17 → 0.1.19
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.
- package/dist/cli.js +2 -2
- package/package.json +1 -1
- package/registry/host.json +74 -22
- package/registry/index.json +761 -115
- package/registry/modules/board-game.json +9 -9
- package/registry/modules/character.json +75 -0
- package/registry/modules/chat.json +71 -0
- package/registry/modules/craft.json +71 -0
- package/registry/modules/deals.json +70 -0
- package/registry/modules/events.json +77 -0
- package/registry/modules/friends.json +70 -0
- package/registry/modules/idle-rpg.json +8 -8
- package/registry/modules/inventory.json +70 -0
- package/registry/modules/invite.json +70 -0
- package/registry/modules/leaderboard.json +72 -0
- package/registry/modules/marketplace.json +72 -0
- package/registry/modules/mini-game.json +69 -0
- package/registry/modules/profile.json +71 -0
- package/registry/modules/pvp.json +69 -0
- package/registry/modules/rewards.json +72 -0
- package/registry/modules/season.json +76 -0
- package/registry/modules/store.json +72 -0
- package/registry/modules/tutorials.json +68 -0
- package/registry/modules/voxelcraft.json +65 -464
- package/registry/modules/workshop.json +13 -8
- package/registry/skills/dev-test-loop.json +1 -1
- package/registry/skills/idosgames-compose-modules.json +2 -2
- package/registry/skills/idosgames-getting-started.json +2 -2
- package/registry/skills/idosgames-module-contract.json +2 -2
- package/registry/skills/idosgames-project-structure.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +6 -0
- package/registry/skills/leaderboard-system.json +1 -1
- package/registry/skills/multiplayer-realtime.json +3 -3
- package/registry/skills/push-notifications.json +1 -1
- package/registry/skills/referral-system.json +2 -2
- package/registry/skills/timed-event-system.json +1 -1
- package/registry/skills/workshop-system.json +1 -1
- package/registry/modules/game-hud.json +0 -99
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "idosgames-project-structure",
|
|
3
3
|
"description": "The layout standard for an iDosGames game project (host shell + composable modules) and how it grows without turning into a mess: where a new feature goes (a catalog module, an existing module, or a new module), the folder layout inside a module, module boundaries (no cross-module imports), the game's shared code in src/shared/, module.meta.json, src/modules.ts being platform-generated, idos.modules.lock.json and customized catalog modules, file size, and project documentation (IDOS.md vs docs/feature-history/). Use this BEFORE creating a module, creating or changing src/shared/, moving code between files or folders, adding a system that spans several modules, or writing project documentation.",
|
|
4
|
-
"content": "---\nname: idosgames-project-structure\ndescription: >-\n The layout standard for an iDosGames game project (host shell + composable modules) and how it\n grows without turning into a mess: where a new feature goes (a catalog module, an existing module,\n or a new module), the folder layout inside a module, module boundaries (no cross-module imports),\n the game's shared code in src/shared/, module.meta.json, src/modules.ts being platform-generated,\n idos.modules.lock.json and customized catalog modules, file size, and project documentation\n (IDOS.md vs docs/feature-history/). Use this BEFORE creating a module, creating or changing\n src/shared/, moving code between files or folders, adding a system that spans several modules, or\n writing project documentation.\n---\n\n# Project structure (iDosGames game projects)\n\n## The project at a glance\n\n```\nIDOS.md project guide every agent reads first — short, evergreen\nAGENTS.md, CLAUDE.md pointers to IDOS.md for external tools (Cursor, Codex, Claude Code…)\nidos.modules.lock.json platform-owned: catalog modules, their versions and file fingerprints\ndocs/feature-history/ one file per game system + README.md index\npackage.json · vite.config.ts · tsconfig.json · index.html\nsrc/\n main.tsx the host: ONE SDK client + mountHost(...)\n modules.ts the composition list — generated by the platform\n idos.title.ts the project's identity — generated, never edit\n config.ts title / environment resolution — platform-owned, never edit\n env.ts build-time values (.env.local)\n LoginScreen.tsx the login screen — restyle freely\n shared/ optional: code two or more of THIS game's modules need\n ui/ types/ utils/\n modules/\n <id>/ one folder per module: catalog templates, catalog features, the game's own\n```\n\nEverything the game does lives in a module. Ready-made catalog modules and the game's own modules\nshare the one `src/modules/` folder — where a module came from is recorded in\n`idos.modules.lock.json`, not in its path, because a catalog template becomes the creator's code the\nmoment they start changing it. `src/shared/` is the one place for code several of the game's own\nmodules use; there is no project-level `utils/` or `components/` besides it.\n\n## Where does a new feature go?\n\nDecide in this order:\n\n1. **The catalog already has it** → install that module (the AI editor's InstallModule, or\n `get_module` over MCP) and adapt it. Don't rebuild what exists.\n2. **It extends an existing module's gameplay** (a new tile type in the board game, a new enemy in\n the idle RPG) → change that module, inside its folder.\n3. **It is its own mode, screen or system** (a shop, a clan screen, a mini-game, a quest board) → a\n **new module** `src/modules/<feature-id>/`.\n4. **It is chrome for every mode.** Balances, the wallet, the status line, Log out / player ID are\n shared-UI roles → a module that **provides** them (`sharedUi: { provides: [...] }` in module.ts):\n install `game-hud` from the catalog, or change it; templates hide their own copies by\n themselves. Other UI every mode shows (a global menu) → a panel with `activeOnly: false` in a\n no-scene, no-route module (`type: \"app\"`, `engine: \"dom\"`). See **idosgames-compose-modules**.\n5. **It is code two or more of the game's modules need** (a shared type, the game's UI kit, a\n formatting helper) → `src/shared/` (see below).\n\nBetween 2 and 3: if it would get its own nav tab, or could be switched off on its own, it is its own\nmodule.\n\n## Inside a module\n\n```\nsrc/modules/<id>/\n index.ts export { <camelCaseId>Module } from \"./module\";\n module.ts defineModule({ id, meta, setup(ctx) { … } })\n module.meta.json manifest: type, summary, provides, tags, version, author\n components/ React panels and UI pieces\n game/ engine and simulation (engine subfolders are fine: game/phaser/, game/three/)\n data/ static tables and tuning (levels, tiles, item lists)\n react/ hooks, contexts, the scene↔panel controller bridge\n```\n\n- The folder id is kebab-case `[a-z0-9-]`, at most 64 characters. The export name is derived from it\n — `daily-quests` → `dailyQuestsModule` — and the platform registers the module by that exact name.\n- Create only the folders you need; a small module can be `index.ts` + `module.ts` + one component.\n- `voxelcraft` is a ported vanilla game with its own layout — leave it as it is. New modules follow\n the layout above.\n\n### module.meta.json\n\n```json\n{\n \"id\": \"daily-quests\",\n \"type\": \"feature\",\n \"summary\": \"One-line pitch shown in the Modules dialog.\",\n \"description\": \"What it does, in a few sentences.\",\n \"provides\": [\"daily quest board\", \"streak rewards\"],\n \"tags\": [\"quests\", \"retention\"],\n \"version\": \"0.1.0\",\n \"author\": { \"name\": \"…\" },\n \"events\": {\n \"emits\": [\n {\n \"topic\": \"daily-quests:quest-completed@1\",\n \"when\": \"a quest's reward was claimed\",\n \"payload\": { \"questId\": \"string\" }\n }\n ],\n \"listens\": [\n {\n \"topic\": \"idle-rpg:character-upgraded@1\",\n \"why\": \"progress 'level a hero' quests\"\n }\n ]\n }\n}\n```\n\n`type` is `template` (a complete game to start from) or `feature` (a capability added to a game).\n`events` declares every `defineTopic(...)` the module uses — its own topics under `emits` (with the\nsame payload descriptor it passes to `shape()`), other modules' under `listens`; omit it when the\nmodule has none.\n`provides` is what an agent matches a request against — keep it accurate. Every module carries this\nfile, the game's own included: it is how a module shows up in the Modules dialog, and what lets it be\npublished to the shared catalog for other creators later.\n\n### Register it\n\n`src/modules.ts` has exactly this shape — imports plus the array, nothing else:\n\n```ts\nimport type { Module } from \"@idosgames/module-sdk\";\nimport { boardGameModule } from \"./modules/board-game\";\nimport { dailyQuestsModule } from \"./modules/daily-quests\";\n\nexport const modules: Module[] = [boardGameModule, dailyQuestsModule];\n```\n\nThe platform regenerates this file whenever modules are installed or removed; any other code in it is\nlost. Array order is mount order (and nav order).\n\n## Module boundaries\n\nA module imports only:\n\n- files inside its own folder;\n- `src/shared/` — the game's shared code;\n- npm packages (`@idosgames/*`, `react`, `three`, `phaser`, …).\n\nNever another module's files (`../board-game/…`) and never host files (`../../main`, `../../config`).\nModules are mixed into different games — a module that reaches into its neighbours breaks the moment\none of them is removed or replaced. Cooperate instead through:\n\n- **Durable shared state** (currency, inventory, characters, progress) → the ONE SDK client every\n module receives as `ctx.client`. Gold earned in one mode is spendable in another with no plumbing.\n- **Live signals** → `ctx.events` with typed topics `<module-id>:<event>@<major>`\n (`defineTopic(\"idle-rpg:character-upgraded@1\", shape({ … }))`), declared in `module.meta.json`.\n The emitter doesn't know who listens; a listener keeps its own copy of the topic. Topics shared by\n several of THIS game's own modules can live in `src/shared/events/` and be imported on both sides.\n- **Code several modules need** → `src/shared/`.\n- **Logic that must be shared and trusted** → it belongs on the server (SDK services, CloudCode),\n not in a client file.\n\n## src/shared/ — the game's shared code\n\nFor code that two or more of THIS game's own modules need.\n\n- **Created on demand.** Code only one module needs stays inside that module; move it to\n `src/shared/` when a second module needs it, not in advance.\n- **One-way dependency.** Modules import from `src/shared/`; `src/shared/` NEVER imports a module.\n Otherwise every module using the shared code silently drags another module in with it.\n- **No game state, no game logic.** Types, constants, the game's UI components and theme, pure\n helpers. Progress and data go through the SDK client, signals through `ctx.events`.\n- **Organised by purpose:** `src/shared/ui/`, `src/shared/types/`, `src/shared/utils/` — not one pile.\n- **Catalog modules never depend on it.** A module that ships in the catalog must install into any\n game, so it is fully self-contained. A game's own module that imports `src/shared/` is tied to this\n game — fine for your own game; when you publish such a module, its shared code is copied into it\n (the Modules dialog marks these modules \"uses shared code\").\n\n## Catalog modules you change\n\nA module installed from the catalog is source in your project — change it freely. At install the\nplatform records its file fingerprints in `idos.modules.lock.json`; once any file differs, the module\ncounts as **customized**, and updating it from the catalog is refused until the user confirms\noverwriting their changes in the Modules dialog. An agent never forces that overwrite. Write down\nnon-obvious customizations in `docs/feature-history/` so the next person knows why the module differs\nfrom the catalog.\n\n## Files\n\n- Keep files focused. When a change adds a new responsibility to a file past ~400 lines, move that\n part into its own file — as part of that change, not as a separate drive-by refactor.\n- `src/idos.title.ts`, `src/config.ts` and `idos.modules.lock.json` are platform-owned: never edit\n them. The platform's build packs its own `idos.title.ts` and `config.ts` over the project's, so\n an edit there never reaches the game anyway.\n- Never read the title or the environment from the URL: a link is editable by anyone. Use\n `client.titleID` (or `ctx.titleId` in a module's `setup()`).\n\n## Documentation\n\n- **IDOS.md** — the always-loaded guide: what the game is (\"This game\"), layout, conventions,\n \"never do X\" constraints, lasting user preferences. Short and evergreen; change a line when a fact\n changes. The \"Installed modules\" block is maintained by the platform.\n- **docs/feature-history/<slug>.md** — one file per game system or feature: what was built, why,\n and the decisions and constraints the code does not show. Update the system's file when it\n changes, and give every file ONE line in `docs/feature-history/README.md`:\n `- [Title](slug.md) — one-line gist`.\n- Never append a feature write-up to IDOS.md. It is loaded on every run, so every paragraph there is\n paid for by all future work — that is exactly how instruction files bloat.\n- Feature-history entries are not loaded automatically: open the relevant one before changing that\n system.\n",
|
|
4
|
+
"content": "---\nname: idosgames-project-structure\ndescription: >-\n The layout standard for an iDosGames game project (host shell + composable modules) and how it\n grows without turning into a mess: where a new feature goes (a catalog module, an existing module,\n or a new module), the folder layout inside a module, module boundaries (no cross-module imports),\n the game's shared code in src/shared/, module.meta.json, src/modules.ts being platform-generated,\n idos.modules.lock.json and customized catalog modules, file size, and project documentation\n (IDOS.md vs docs/feature-history/). Use this BEFORE creating a module, creating or changing\n src/shared/, moving code between files or folders, adding a system that spans several modules, or\n writing project documentation.\n---\n\n# Project structure (iDosGames game projects)\n\n## The project at a glance\n\n```\nIDOS.md project guide every agent reads first — short, evergreen\nAGENTS.md, CLAUDE.md pointers to IDOS.md for external tools (Cursor, Codex, Claude Code…)\nidos.modules.lock.json platform-owned: catalog modules, their versions and file fingerprints\ndocs/feature-history/ one file per game system + README.md index\npackage.json · vite.config.ts · tsconfig.json · index.html\nsrc/\n main.tsx the host: ONE SDK client + mountHost([baseModule, ...modules])\n app.config.ts what happens after sign-in (start) and the lobby's tab order\n ui.config.ts the look, animations and sounds of every screen\n base/ THE BASE: login/ (sign-in), lobby/ (tabs, balances, wallet, profile, \"Play\"),\n module.ts (baseModule) — ordinary project code\n modules.ts the composition list — generated by the platform\n idos.title.ts the project's identity — generated, never edit\n config.ts title / environment resolution — platform-owned, never edit\n env.ts build-time values (.env.local)\n shared/ optional: code two or more of THIS game's modules need\n ui/ types/ utils/\n modules/\n <id>/ one folder per module: the catalog's systems and games, the game's own\n```\n\nSign-in and the lobby are the base; everything else the game does lives in a module — a **system**\n(its screen is a lobby tab: shop, heroes, leaderboards…) or a **game** (its mode gets a \"Play\"\nbutton). Ready-made catalog modules and the game's own modules\nshare the one `src/modules/` folder — where a module came from is recorded in\n`idos.modules.lock.json`, not in its path, because a catalog template becomes the creator's code the\nmoment they start changing it. `src/shared/` is the one place for code several of the game's own\nmodules use; there is no project-level `utils/` or `components/` besides it.\n\n## Where does a new feature go?\n\nDecide in this order:\n\n1. **The catalog already has it** → install that module (the AI editor's InstallModule, or\n `get_module` over MCP) and adapt it. Don't rebuild what exists.\n2. **It extends an existing module's gameplay** (a new tile type in the board game, a new enemy in\n the idle RPG) → change that module, inside its folder.\n3. **It is its own mode, screen or system** (a shop, a clan screen, a mini-game, a quest board) → a\n **new module** `src/modules/<feature-id>/`.\n4. **It is the lobby, sign-in or chrome everybody shares.** Balances, the wallet, the profile, the\n tabs and \"Play\" are the base (`src/base/`) — change it there; games hide their own copies of the\n balances and wallet by themselves. Other UI shown over every mode → a panel with\n `activeOnly: false`. See **idosgames-compose-modules**.\n5. **It is code two or more of the game's modules need** (a shared type, the game's UI kit, a\n formatting helper) → `src/shared/` (see below).\n\nBetween 2 and 3: if it would get its own lobby tab or \"Play\" button, or could be switched off on its\nown, it is its own module.\n\n## Inside a module\n\n```\nsrc/modules/<id>/\n index.ts export { <camelCaseId>Module } from \"./module\";\n module.ts defineModule({ id, meta, setup(ctx) { … } })\n module.meta.json manifest: type, summary, provides, tags, version, author\n components/ React panels and UI pieces\n game/ engine and simulation (engine subfolders are fine: game/phaser/, game/three/)\n data/ static tables and tuning (levels, tiles, item lists)\n react/ hooks, contexts, the scene↔panel controller bridge\n```\n\n- The folder id is kebab-case `[a-z0-9-]`, at most 64 characters. The export name is derived from it\n — `daily-quests` → `dailyQuestsModule` — and the platform registers the module by that exact name.\n- Create only the folders you need; a small module can be `index.ts` + `module.ts` + one component.\n- `voxelcraft` is a ported vanilla game with its own layout — leave it as it is. New modules follow\n the layout above.\n\n### module.meta.json\n\n```json\n{\n \"id\": \"daily-quests\",\n \"type\": \"feature\",\n \"summary\": \"One-line pitch shown in the Modules dialog.\",\n \"description\": \"What it does, in a few sentences.\",\n \"provides\": [\"daily quest board\", \"streak rewards\"],\n \"tags\": [\"quests\", \"retention\"],\n \"version\": \"0.1.0\",\n \"author\": { \"name\": \"…\" },\n \"events\": {\n \"emits\": [\n {\n \"topic\": \"daily-quests:quest-completed@1\",\n \"when\": \"a quest's reward was claimed\",\n \"payload\": { \"questId\": \"string\" }\n }\n ],\n \"listens\": [\n {\n \"topic\": \"idle-rpg:character-upgraded@1\",\n \"why\": \"progress 'level a hero' quests\"\n }\n ]\n }\n}\n```\n\n`type` is `template` (a complete game to start from) or `feature` (a capability added to a game).\n`events` declares every `defineTopic(...)` the module uses — its own topics under `emits` (with the\nsame payload descriptor it passes to `shape()`), other modules' under `listens`; omit it when the\nmodule has none.\n`provides` is what an agent matches a request against — keep it accurate. Every module carries this\nfile, the game's own included: it is how a module shows up in the Modules dialog, and what lets it be\npublished to the shared catalog for other creators later.\n\n### Register it\n\n`src/modules.ts` has exactly this shape — imports plus the array, nothing else:\n\n```ts\nimport type { Module } from \"@idosgames/module-sdk\";\nimport { boardGameModule } from \"./modules/board-game\";\nimport { dailyQuestsModule } from \"./modules/daily-quests\";\n\nexport const modules: Module[] = [boardGameModule, dailyQuestsModule];\n```\n\nThe platform regenerates this file whenever modules are installed or removed; any other code in it is\nlost. Array order is mount order (and nav order).\n\n## Module boundaries\n\nA module imports only:\n\n- files inside its own folder;\n- `src/shared/` — the game's shared code;\n- npm packages (`@idosgames/*`, `react`, `three`, `phaser`, …).\n\nNever another module's files (`../board-game/…`) and never host files (`../../main`, `../../config`).\nModules are mixed into different games — a module that reaches into its neighbours breaks the moment\none of them is removed or replaced. Cooperate instead through:\n\n- **Durable shared state** (currency, inventory, characters, progress) → the ONE SDK client every\n module receives as `ctx.client`. Gold earned in one mode is spendable in another with no plumbing.\n- **Live signals** → `ctx.events` with typed topics `<module-id>:<event>@<major>`\n (`defineTopic(\"idle-rpg:character-upgraded@1\", shape({ … }))`), declared in `module.meta.json`.\n The emitter doesn't know who listens; a listener keeps its own copy of the topic. Topics shared by\n several of THIS game's own modules can live in `src/shared/events/` and be imported on both sides.\n- **Code several modules need** → `src/shared/`.\n- **Logic that must be shared and trusted** → it belongs on the server (SDK services, CloudCode),\n not in a client file.\n\n## src/shared/ — the game's shared code\n\nFor code that two or more of THIS game's own modules need.\n\n- **Created on demand.** Code only one module needs stays inside that module; move it to\n `src/shared/` when a second module needs it, not in advance.\n- **One-way dependency.** Modules import from `src/shared/`; `src/shared/` NEVER imports a module.\n Otherwise every module using the shared code silently drags another module in with it.\n- **No game state, no game logic.** Types, constants, the game's UI components and theme, pure\n helpers. Progress and data go through the SDK client, signals through `ctx.events`.\n- **Organised by purpose:** `src/shared/ui/`, `src/shared/types/`, `src/shared/utils/` — not one pile.\n- **Catalog modules never depend on it.** A module that ships in the catalog must install into any\n game, so it is fully self-contained. A game's own module that imports `src/shared/` is tied to this\n game — fine for your own game; when you publish such a module, its shared code is copied into it\n (the Modules dialog marks these modules \"uses shared code\").\n\n## Catalog modules you change\n\nA module installed from the catalog is source in your project — change it freely. At install the\nplatform records its file fingerprints in `idos.modules.lock.json`; once any file differs, the module\ncounts as **customized**, and updating it from the catalog is refused until the user confirms\noverwriting their changes in the Modules dialog. An agent never forces that overwrite. Write down\nnon-obvious customizations in `docs/feature-history/` so the next person knows why the module differs\nfrom the catalog.\n\n## Files\n\n- Keep files focused. When a change adds a new responsibility to a file past ~400 lines, move that\n part into its own file — as part of that change, not as a separate drive-by refactor.\n- `src/idos.title.ts`, `src/config.ts` and `idos.modules.lock.json` are platform-owned: never edit\n them. The platform's build packs its own `idos.title.ts` and `config.ts` over the project's, so\n an edit there never reaches the game anyway.\n- Never read the title or the environment from the URL: a link is editable by anyone. Use\n `client.titleID` (or `ctx.titleId` in a module's `setup()`).\n\n## Documentation\n\n- **IDOS.md** — the always-loaded guide: what the game is (\"This game\"), layout, conventions,\n \"never do X\" constraints, lasting user preferences. Short and evergreen; change a line when a fact\n changes. The \"Installed modules\" block is maintained by the platform.\n- **docs/feature-history/<slug>.md** — one file per game system or feature: what was built, why,\n and the decisions and constraints the code does not show. Update the system's file when it\n changes, and give every file ONE line in `docs/feature-history/README.md`:\n `- [Title](slug.md) — one-line gist`.\n- Never append a feature write-up to IDOS.md. It is loaded on every run, so every paragraph there is\n paid for by all future work — that is exactly how instruction files bloat.\n- Feature-history entries are not loaded automatically: open the relevant one before changing that\n system.\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 system modules (store, character, inventory, …): 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 / workshop system instead of writing it, open another module's screen with ctx.features.open, register a new screen (a lobby tab) with ctx.features.register and hide it while the title has no data (setAvailable), 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 system modules (store, character, inventory, …): 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 / workshop system instead of\n writing it, open another module's screen with ctx.features.open, register a new screen (a lobby\n tab) with ctx.features.register and hide it while the title has no data (setAvailable), 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 system modules\n\nThe app's interface is the base's lobby (`src/base/`) plus **system modules**, all 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- system modules — the platform's systems, each a whole `feature` module (its screen = a lobby\n tab, its client logic, its demo settings), all installed with the base into every new project\n (`inBase` in the catalog). The module id is the feature id:\n\n| Module | Screen |\n| ------------- | ---------------------------------------------------------------- |\n| `store` | shop: stores as tabs, offers, purchase popup |\n| `character` | heroes: gear slots, \"Equip best\", stats, ranks, skins (popups) |\n| `inventory` | items by rarity, item card, lootbox opening |\n| `leaderboard` | tables, my place, rank rewards, last cycle's prize |\n| `rewards` | daily calendar, quests, points track |\n| `craft` | recipes, contract popup |\n| `marketplace` | lots, auctions, selling, trades |\n| `deals` | special offers, auto-popup of a new deal |\n| `events` | limited-time events, milestones, VIP prize |\n| `season` | season tiers, VIP prize |\n| `friends` | friends, requests, suggestions, news |\n| `chat` | channels, private chats |\n| `invite` | own code and link, friend's code, rewards |\n| `pvp` | duels, strategy, fight replay |\n| `profile` | name, sound, language, consent, delete account |\n| `tutorials` | hint overlay + replay list |\n| `workshop` | players' and the publisher's content: catalog, publish, licenses |\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`). The app does not need one → remove it\nfrom `src/modules.ts`, its tab is gone. The lobby itself (tabs, balances, wallet, profile, \"Play\") is\nthe base, `src/base/lobby/`.\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 (the profile module draws\n them):\n `const [prefs, setPrefs] = usePlayerPrefs()` → `{ muted, volume, reducedMotion }`.\n\n## Features: screens that find each other\n\nA module registers a **feature**; whoever draws the menu shows it — the base's lobby as tabs (phone:\n4 + \"More\", tablet: more, desktop: a side rail; the base takes the shared-UI role `\"menu\"`), or the\nhost's floating ☰ when nobody takes the role. Tab order and main tabs can be overridden in\n`src/app.config.ts` (`lobby.order`, `lobby.primary`).\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 available: false, // hidden until the title has something for it — see below\n });\n // Badge (\"something to claim\"): ctx.features.setBadge(\"forge\", 2)\n // The title has forge recipes → show the tab; the reason is for the AI Coder's agent only:\n ctx.features.setAvailable(\"forge\", hasRecipes(configSection(ctx.client, \"Craft\")), \"no forge recipes\");\n}\n```\n\n- Open another module's screen without importing it: `ctx.features.open(\"store\")`,\n `ctx.features.open(\"chat\", { userID })`. It returns `false` when the module is not installed or its\n tab is hidden (`ctx.features.get(\"store\")?.available`). From a game (the lobby is off screen) the\n host shows the screen in a window.\n- A screen that depends on title data registers with `available: false` and calls\n `ctx.features.setAvailable(id, ok, reason)` once it knows — from the client config\n (`configSection(client, \"Store\")`) or a read it makes anyway (a badge watcher); no extra request\n at start-up: every module's first reads arrive together and brush the per-player rate limit.\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's 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\nThe `tutorials` module highlights any element marked `data-tutorial-anchor=\"…\"` — the lobby's tabs\nare `tab:{featureID}`, its profile button `lobby:profile`, its wallet `lobby:wallet`, a game's \"Play\"\nbutton `play:{modeID}`. 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,
|
|
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
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "push-notifications",
|
|
3
3
|
"description": "Add push notifications to a game on the iDosGames TypeScript SDK (@idosgames/core) via client.push (PushService): ask the player for permission from a tap, register the browser's Web Push subscription with the server, draw the right button state, list and remove the player's devices, and ship the service worker that displays a notification. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg, voxelcraft) and wants push notifications, browser notifications, web push, a \"notify me\" or \"enable notifications\" toggle, a re-engagement or comeback reminder, an energy-refilled or build-finished alert, a service worker, sw.js, VAPID, PushManager, Notification.permission, or otherwise touches client.push, PushService, PushConfigResponse, PushSubscriptionView or PushPermissionState — even if they don't name the module explicitly.",
|
|
4
|
-
"content": "---\nname: push-notifications\ndescription: >-\n Add push notifications to a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.push (PushService): ask the player for\n permission from a tap, register the browser's Web Push subscription with the\n server, draw the right button state, list and remove the player's devices,\n and ship the service worker that displays a notification. Use this whenever\n the user is working in the iDosGames TS SDK or its game templates\n (board-game, idle-rpg, voxelcraft) and wants push notifications, browser\n notifications, web push, a \"notify me\" or \"enable notifications\" toggle, a\n re-engagement or comeback reminder, an energy-refilled or build-finished\n alert, a service worker, sw.js, VAPID, PushManager, Notification.permission,\n or otherwise touches client.push, PushService, PushConfigResponse,\n PushSubscriptionView or PushPermissionState — even if they don't name the\n module explicitly.\n---\n\n# Push notifications (iDosGames TS SDK)\n\nPush reaches a player who is **not in the game**. That single fact shapes the\nwhole module: the text is resolved on the server (nobody is around to ask what\nlanguage to use), the sending is done by the backend from a queue, and the only\nthing the game does is get the browser registered and ship a service worker\nthat displays what arrives.\n\nThis skill is for **using** the production `PushService`. A refusal is almost\nalways the browser or the publisher's config, not a bug to work around.\n\n## The one rule that outranks everything else here\n\n**`client.push.subscribe()` runs from a real tap. Never on load, never in a\n`useEffect`, never \"just to check\".**\n\nBrowsers reject `Notification.requestPermission()` outside a user gesture, and\nChrome **permanently blocks an origin** after a few dismissals. So an automatic\nprompt does not merely fail — it takes away the player's ability to ever say\nyes, and nothing in your code can undo that afterwards. Only the person, in\ntheir browser's site settings.\n\nAsk when the player has just been given a reason to want it (\"tell me when my\nenergy is full\"), not when the game starts.\n\n## Drawing the button\n\n```ts\nimport { PushPermissionState } from \"@idosgames/core\";\n\nconst state = await client.push.getState();\n```\n\nFive values, and **four of them mean \"no button\"** — for different reasons:\n\n| State | What it means | What to draw |\n| ------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------ |\n| `idle` | Available, nobody asked yet | **The button.** This is the only state that gets one |\n| `subscribed` | This browser is registered | \"Notifications on\", plus a way to turn them off |\n| `blocked` | The person said no, or Chrome said it for them | Nothing, or a line explaining that browser settings can undo it. **Do not offer to retry** |\n| `unsupported` | No Push API, or the publisher has not enabled the feature | Nothing at all |\n| `embedded` | Running inside the portal iframe | Nothing — the site owns this, see below |\n\n`unsupported` deliberately does not distinguish \"the platform has no keys\" from\n\"the publisher did not opt in\": in both cases there must be no button, and a\nfeature that cannot work has to be invisible rather than broken.\n\n## Subscribing\n\n```ts\nasync function onEnableNotificationsTapped() {\n const result = await client.push.subscribe();\n if (!result.ok) return showToast(result.error.message);\n\n // \"portal\" means the WEBSITE subscribed on the player's behalf — see below.\n showToast(result.data.Where === \"portal\" ? \"Notifications on\" : \"All set\");\n}\n```\n\nIdempotent: a browser that is already subscribed re-sends the same endpoint,\nwhich refreshes the row's language and timezone instead of creating a second\none. Calling it twice is harmless.\n\n`subscribe()` registers `./sw.js` by default — **relative on purpose**. A hosted\nbuild lives at `cloud.idosgames.com/drive/app/{titleID}/`, so a leading slash\nwould point at another game's folder, and the relative form also scopes the\nregistration to exactly this game's directory. That scope is what gives each\ngame its own subscription on a shared origin. Pass `swPath` only if your worker\nlives somewhere else.\n\n## ⚠⚠ Inside the portal it works differently, and that is not fixable\n\nA game opened on `idosgames.com/app/{id}` runs in a **cross-origin iframe**,\nwhere the browser blocks the permission prompt outright. Not a policy anyone can\nrelax — it is how permissions work.\n\nSo there `subscribe()` asks the **website** to do it, on its own top-level\ndocument. The site creates the row against the player's platform identity, the\n**engine still does the sending**, and the player's game id reaches that row on\ntheir next platform sign-in. `result.data.Where` tells you which happened; a\nportal subscription is real, it just does not belong to the game's origin, and\n`Subscription` comes back `null` because the game never sees its keys.\n\nConsequences for your UI: `getState()` returns `embedded` there, so the button\nis hidden by default. If you want an \"enable notifications\" affordance in the\nportal too, call `subscribe()` from a tap anyway and branch on the result — but\ndo not try to read `Notification.permission` or `pushManager`, which describe\nthe iframe, not the page the player is looking at.\n\n## The service worker\n\n`
|
|
4
|
+
"content": "---\nname: push-notifications\ndescription: >-\n Add push notifications to a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.push (PushService): ask the player for\n permission from a tap, register the browser's Web Push subscription with the\n server, draw the right button state, list and remove the player's devices,\n and ship the service worker that displays a notification. Use this whenever\n the user is working in the iDosGames TS SDK or its game templates\n (board-game, idle-rpg, voxelcraft) and wants push notifications, browser\n notifications, web push, a \"notify me\" or \"enable notifications\" toggle, a\n re-engagement or comeback reminder, an energy-refilled or build-finished\n alert, a service worker, sw.js, VAPID, PushManager, Notification.permission,\n or otherwise touches client.push, PushService, PushConfigResponse,\n PushSubscriptionView or PushPermissionState — even if they don't name the\n module explicitly.\n---\n\n# Push notifications (iDosGames TS SDK)\n\nPush reaches a player who is **not in the game**. That single fact shapes the\nwhole module: the text is resolved on the server (nobody is around to ask what\nlanguage to use), the sending is done by the backend from a queue, and the only\nthing the game does is get the browser registered and ship a service worker\nthat displays what arrives.\n\nThis skill is for **using** the production `PushService`. A refusal is almost\nalways the browser or the publisher's config, not a bug to work around.\n\n## The one rule that outranks everything else here\n\n**`client.push.subscribe()` runs from a real tap. Never on load, never in a\n`useEffect`, never \"just to check\".**\n\nBrowsers reject `Notification.requestPermission()` outside a user gesture, and\nChrome **permanently blocks an origin** after a few dismissals. So an automatic\nprompt does not merely fail — it takes away the player's ability to ever say\nyes, and nothing in your code can undo that afterwards. Only the person, in\ntheir browser's site settings.\n\nAsk when the player has just been given a reason to want it (\"tell me when my\nenergy is full\"), not when the game starts.\n\n## Drawing the button\n\n```ts\nimport { PushPermissionState } from \"@idosgames/core\";\n\nconst state = await client.push.getState();\n```\n\nFive values, and **four of them mean \"no button\"** — for different reasons:\n\n| State | What it means | What to draw |\n| ------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------ |\n| `idle` | Available, nobody asked yet | **The button.** This is the only state that gets one |\n| `subscribed` | This browser is registered | \"Notifications on\", plus a way to turn them off |\n| `blocked` | The person said no, or Chrome said it for them | Nothing, or a line explaining that browser settings can undo it. **Do not offer to retry** |\n| `unsupported` | No Push API, or the publisher has not enabled the feature | Nothing at all |\n| `embedded` | Running inside the portal iframe | Nothing — the site owns this, see below |\n\n`unsupported` deliberately does not distinguish \"the platform has no keys\" from\n\"the publisher did not opt in\": in both cases there must be no button, and a\nfeature that cannot work has to be invisible rather than broken.\n\n## Subscribing\n\n```ts\nasync function onEnableNotificationsTapped() {\n const result = await client.push.subscribe();\n if (!result.ok) return showToast(result.error.message);\n\n // \"portal\" means the WEBSITE subscribed on the player's behalf — see below.\n showToast(result.data.Where === \"portal\" ? \"Notifications on\" : \"All set\");\n}\n```\n\nIdempotent: a browser that is already subscribed re-sends the same endpoint,\nwhich refreshes the row's language and timezone instead of creating a second\none. Calling it twice is harmless.\n\n`subscribe()` registers `./sw.js` by default — **relative on purpose**. A hosted\nbuild lives at `cloud.idosgames.com/drive/app/{titleID}/`, so a leading slash\nwould point at another game's folder, and the relative form also scopes the\nregistration to exactly this game's directory. That scope is what gives each\ngame its own subscription on a shared origin. Pass `swPath` only if your worker\nlives somewhere else.\n\n## ⚠⚠ Inside the portal it works differently, and that is not fixable\n\nA game opened on `idosgames.com/app/{id}` runs in a **cross-origin iframe**,\nwhere the browser blocks the permission prompt outright. Not a policy anyone can\nrelax — it is how permissions work.\n\nSo there `subscribe()` asks the **website** to do it, on its own top-level\ndocument. The site creates the row against the player's platform identity, the\n**engine still does the sending**, and the player's game id reaches that row on\ntheir next platform sign-in. `result.data.Where` tells you which happened; a\nportal subscription is real, it just does not belong to the game's origin, and\n`Subscription` comes back `null` because the game never sees its keys.\n\nConsequences for your UI: `getState()` returns `embedded` there, so the button\nis hidden by default. If you want an \"enable notifications\" affordance in the\nportal too, call `subscribe()` from a tap anyway and branch on the result — but\ndo not try to read `Notification.permission` or `pushManager`, which describe\nthe iframe, not the page the player is looking at.\n\n## The service worker\n\n`public/sw.js` of the base (`templates/base`) ships as a starting point you own. Two\nthings in it are not negotiable:\n\n1. **⚠⚠ No `fetch` handler. Ever.** A caching worker looks like a free win and\n is the most expensive mistake available here: a cached `index.html` points at\n content-hashed chunks that the next deploy deletes, and the player gets a\n white screen served from _inside their own browser_, where neither a CDN\n purge nor Ctrl+F5 reaches. The platform already paid for that bug once in the\n publisher dashboard. If you want offline support, do it deliberately and\n never cache the document.\n2. **Always call `showNotification`.** The subscription was created with\n `userVisibleOnly: true`, which is a promise to display every message. Break it\n and the browser first warns the player about background activity, then revokes\n the permission.\n\nThe payload the server sends is small and already localised:\n`{ title, body?, url?, icon?, tag? }`. Do not translate it in the worker — the\ntext was resolved into the **device's** language at send time, using the locale\ncaptured when the subscription was created.\n\n## Managing devices\n\n```ts\nconst list = await client.push.getSubscriptions(); // every device, this title\nawait client.push.unsubscribeByHash(row.EndpointHash); // remove one of them\nawait client.push.unsubscribe(); // remove THIS browser\n```\n\nA subscription belongs to a **(browser, registration)** pair, not to an account:\none person's phone, laptop and portal tab are three separate rows. Clearing site\ndata destroys one silently, with no event — which is why `getState()` asks the\nregistration rather than trusting `Notification.permission`, and why every call\nhere is idempotent.\n\n`unsubscribe()` does both halves — the browser and the server — and both matter:\ndropping only the server row leaves a browser holding a live endpoint, and\ndropping only the browser one leaves a row that keeps being sent to until a push\nservice answers `410`.\n\n## Things not to do\n\n- **Do not compute `EndpointHash`.** It is `sha256(endpoint)` and trivial to\n reproduce — and it addresses a shared database that already has two\n implementations guarded by golden vectors. A third one, in another language\n and without those vectors, drifts silently and leaves the player looking at a\n button in the wrong state. Use the value the server returned.\n- **Do not bake the VAPID key into the build.** It arrives from `getConfig()`\n because it is a platform key shared by both backends; baking it in means a key\n rotation requires rebuilding every game.\n- **Do not poll `getState()`.** Nothing changes it except the player, in a\n prompt you started.\n- **Do not send notifications from the client.** There is no such call and there\n will not be one: producing a notification is a server concern, gated by the\n publisher's per-player and per-title daily caps and quiet hours. The publisher\n API quota does not even see sends — those caps are the only ceiling there is.\n- **Do not ask again after `blocked`.** There is nothing to ask.\n\n## What the publisher controls (and you cannot)\n\nIn the title config, under `Push`: the master switch (**off by default** — this\nspends a real person's attention), a per-player daily cap, a title-wide daily\ncap, quiet hours, and a default icon. Quiet hours are evaluated in the\n**device's** timezone, captured at subscribe time and separate from the\nlanguage — Portuguese is spoken in Lisbon and in São Paulo.\n\nNone of this is visible to the game. A notification that was capped or fell into\nquiet hours simply never arrives, and there is no client-side signal for it.\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|