@idosgames/mcp 0.1.18 → 0.1.20
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 +69 -25
- package/registry/index.json +895 -620
- package/registry/modules/ai.json +65 -0
- package/registry/modules/board-game.json +8 -8
- package/registry/modules/boosts.json +66 -0
- package/registry/modules/{character-ui.json → character.json} +8 -12
- package/registry/modules/chat.json +70 -0
- package/registry/modules/collections.json +69 -0
- package/registry/modules/coop-events.json +67 -0
- package/registry/modules/{craft-ui.json → craft.json} +8 -12
- package/registry/modules/{deal-offer-ui.json → deals.json} +7 -11
- package/registry/modules/events.json +72 -0
- package/registry/modules/{friends-ui.json → friends.json} +11 -14
- package/registry/modules/idle-rpg.json +7 -7
- package/registry/modules/inventory.json +67 -0
- package/registry/modules/invite.json +70 -0
- package/registry/modules/{leaderboard-ui.json → leaderboard.json} +8 -12
- package/registry/modules/lootboxes.json +67 -0
- package/registry/modules/mailbox.json +71 -0
- package/registry/modules/marketplace.json +71 -0
- package/registry/modules/mini-game.json +69 -0
- package/registry/modules/multiplayer.json +74 -0
- package/registry/modules/premium.json +67 -0
- package/registry/modules/profile.json +70 -0
- package/registry/modules/{pvp-ui.json → pvp.json} +9 -13
- package/registry/modules/quests.json +69 -0
- package/registry/modules/rewards.json +66 -0
- package/registry/modules/season.json +71 -0
- package/registry/modules/{store-ui.json → store.json} +9 -13
- package/registry/modules/{tutorial-ui.json → tutorials.json} +8 -12
- package/registry/modules/voxelcraft.json +15 -455
- package/registry/modules/workshop.json +10 -6
- package/registry/skills/blockchain-system.json +1 -1
- 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 +2 -2
- package/registry/skills/mailbox-system.json +6 -0
- package/registry/skills/multiplayer-realtime.json +2 -2
- package/registry/skills/push-notifications.json +1 -1
- package/registry/skills/workshop-system.json +1 -1
- package/registry/modules/chat-ui.json +0 -74
- package/registry/modules/events-ui.json +0 -80
- package/registry/modules/game-hud.json +0 -104
- package/registry/modules/inventory-ui.json +0 -73
- package/registry/modules/marketplace-ui.json +0 -75
- package/registry/modules/profile-ui.json +0 -74
- package/registry/modules/referral-ui.json +0 -73
- package/registry/modules/rewards-ui.json +0 -75
- package/registry/modules/season-ui.json +0 -79
- package/registry/modules/showcase.json +0 -92
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "idosgames-getting-started",
|
|
3
|
-
"description": "Start a new game or app on the iDosGames composable-module architecture: scaffold the host shell and plug in
|
|
4
|
-
"content": "---\nname: idosgames-getting-started\ndescription: >-\n Start a new game or app on the iDosGames composable-module architecture: scaffold the host shell\n and plug in feature modules (board-game, idle-rpg, voxelcraft, …). Use this whenever\n a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a\n project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or\n src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and\n idosgames-compose-modules (merging several) and idosgames-project-structure (where code goes as\n the project grows). If pulling modules over MCP, use the @idosgames/mcp\n tools get_host_scaffold / get_module / get_manifest.\n---\n\n# Getting started (iDosGames composable modules)\n\nAn iDosGames project is **one host shell + N feature modules**. The host owns everything that exists\nonce — the SDK client, login, the React root, the screen, a module registry, and a Mode Router that\nswitches between modules. A module is a library (a game or app) that plugs into the host; it never\ncreates the client, logs in, or owns the root.\n\n## Runtime packages (npm)\n\n- `@idosgames/core` — the SDK client (`createIDosGamesClient`): auth, currency, store, characters,\n blockchain, ~28 services. See the per-service skills (currency-system, store-system, …).\n- `@idosgames/module-sdk` — the module contract types (`Module`, `ModuleContext`, `EngineScene`,\n `UiPanel`). See idosgames-module-contract.\n- `@idosgames/react` — shared React glue (`IDosGamesProvider`, `useIDosGamesClient`, `useUserState`,\n `StatusProvider`, `createControllerContext`).\n- `@idosgames/app-shell` — the host runtime (`mountHost`, the Mode Router, the module registry).\n- `@idosgames/wallet` — optional wallet bridge (EVM/Solana) for on-chain deposits/withdrawals.\n\nInstall the exact versions from `get_manifest` (MCP) or the registry `index.json` `runtimePackages`.\n\n## Project shape\n\n```\nIDOS.md # the project guide every agent reads first (short, evergreen)\nAGENTS.md, CLAUDE.md # pointers to IDOS.md for external tools\ndocs/feature-history/ # one file per game system + README.md index (the project's memory)\nindex.html # mounts #app\nsrc/main.tsx # creates ONE client and calls mountHost({container, client, modules})\nsrc/modules.ts # the registry: export const modules: Module[] = [ … ] — imports + array only\nsrc/modules/{id}/ # each module's source + its module.meta.json (from get_module)\n```\n\nPlatform-created projects also carry `idos.modules.lock.json` (which modules came from the catalog,\nwith file fingerprints) — it is platform-owned; don't edit it. Where new code goes as the game grows,\nand how the project documents itself, is **idosgames-project-structure**.\n\n`src/main.tsx` (host-owned) is the only place that creates the client — but it does **not** sign in.\n`mountHost` owns sign-in: it replays a previous session (`autoLogin`) and renders the login screen\nwhen there is nothing to replay. Calling a `login*` method here skips that screen for good, taking\n\"switch account\" and wallet sign-in with it:\n\n```ts\nconst client = createIDosGamesClient({ titleID, buildKey, throttleMs: 0 });\n// Do NOT log in here — the host does it.\nmountHost({\n container: app,\n client,\n modules, // from ./modules\n renderLogin, // optional: your own screen; omitted = a plain guest-only default\n});\n```\n\nWhether a returning player is signed back in silently is the login screen's business, not the\nhost's: it calls `client.auth.setRememberSession(remember)` before a `login*` method. See the\nauthentication skill.\n\n`src/modules.ts` registers what the project composes:\n\n```ts\nimport type { Module } from \"@idosgames/module-sdk\";\nimport { boardGameModule } from \"./modules/board-game\";\nexport const modules: Module[] = [boardGameModule];\n```\n\n## Steps to scaffold\n\n1. **Host** — write the host scaffold to the project root (`get_host_scaffold`, or copy\n `templates/host-starter`). Its `src/modules.ts` starts empty → the host shows a \"no modules\" state.\n2. **Bind the Title** — open `src/idos.title.ts` and set `IDOS_TITLE_ID` to the game's canonical\n Title id (and `IDOS_BUILD_KEY` if the title enforces one). This file is the project's single\n centralized identity — it is the highest-priority source and the only channel a packaged mobile\n build, iframe embed, or shared link has. On platform-created projects the platform generates it;\n on a manually scaffolded project **you fill it yourself**. Do NOT bind the title via `.env.local`\n (`VITE_IDOS_TITLE_ID`) — that is a local-dev fallback for the raw template only, and it does not\n travel with the code.\n3. **Pick modules** — `list_modules` / `search`, then `get_module {id}` for each. Write its files\n into `src/modules/{id}/`.\n4. **Register** — for each module add `import { {camelCase(id)}Module } from \"./modules/{id}\"` and\n push it into the `modules` array (e.g. `board-game` → `boardGameModule`). Then add the module to\n the \"Installed modules\" block of `IDOS.md` (one line: ``- `board-game` (0.1.0) — src/modules/board-game/``)\n — on platform projects the platform maintains that block itself.\n5. **Install deps** — the union of `runtimePackages` + each module's `dependencies`. Modules bring\n their own engine (three, phaser); the host brings react/react-dom/app-shell.\n6. **Run** — the host renders a nav-bar when ≥2 modules register a route, and mode-switches between\n them. Mixing two or more templates → also add `game-hud`: one balance bar, wallet and status line\n for every mode, and the templates hide their own copies by themselves (`sharedUi` roles).\n\nTo combine multiple genres into one game, see **idosgames-compose-modules**. To write or edit a\nmodule, see **idosgames-module-contract**.\n\n## Where state lives (decide this before writing the first save)\n\nThe project is client-side code in the player's browser. `localStorage`, module fields, and React\nstate are **not storage** — nothing there survives a device change, and nothing there is trusted.\n\n1. **A dedicated module owns it?** Use that module. Currencies, inventory, quests, characters,\n leaderboards, store purchases each have a service that enforces the rules server-side.\n2. **Otherwise, per-player data → `client.userCustomData`** — buckets `Private`/`Public` are\n client-writable (settings, cosmetics), `ReadOnly`/`Internal` are server-only. Anything a player\n could cheat by editing goes in the server-only buckets. See **user-custom-data**.\n3. **Shared by all players → `client.titleCustomData`** (event state, global counters, server\n thresholds, feature toggles). Read-only for clients. See **title-custom-data**.\n4. **Writing any of the server-only data, or any rule the player must not be able to fake** →\n a CloudCode handler, called with `client.cloudCode.execute(...)`. See **cloud-code**.\n\n## Two MCP surfaces: game CODE vs. a Title's live DATA\n\nThe platform exposes **two independent MCP servers** — don't confuse them:\n\n- **`@idosgames/mcp`** (this one) serves the **CODE registry**. Use it to WRITE a game's source:\n `get_host_scaffold`, `list_modules` / `search` / `get_module`, `get_manifest`, `list_skills` /\n `get_skill`. Transport: stdio (`npx -y @idosgames/mcp`). Its registry is bundled offline; to use the\n hosted copy set `IDOSGAMES_REGISTRY_URL=https://cloud.idosgames.com/drive/registry/latest` — a **base**\n the loader appends `/index.json`, `/modules/{id}.json`, etc. to (the base itself is not fetchable on\n R2; open `.../latest/index.json` to browse the catalog). Read-only, no auth. It never reads or changes\n a live Title's data.\n- **The Title-configuration MCP** (a separate backend server) owns a **live Title's DATA**. Use it to\n read/write the Title's `TitlePublicConfiguration` (`get_<field>` / `save_<field>`, or the whole\n model) and to generate assets (`generate_image` / `generate_audio` / `generate_text` /\n `generate_three_d` / `generate_video`). Transport: HTTP JSON-RPC at\n `POST https://site.idosgames.com/api/v2/mcp`; every tool call takes a `title_id` argument.\n Authorization is **OAuth 2.1** — there is no API key and nothing to paste. Connect it as a plain\n HTTP MCP server with **no headers**: your client gets a `401`, discovers the authorization\n server, registers itself, and opens a browser where the publisher picks which Titles and which\n permissions to grant. The token lives in your client's own credential store, so committed config\n holds only the URL:\n\n ```json\n {\n \"mcpServers\": {\n \"idosgames-title\": {\n \"type\": \"http\",\n \"url\": \"https://site.idosgames.com/api/v2/mcp\"\n }\n }\n }\n ```\n\n Permissions the publisher can grant: `config:read`, `config:write`, `cloudcode:write`,\n `ai:generate`. A grant is scoped to the Titles ticked on the consent screen, and the publisher\n can revoke it any time from **Connected apps** in the dashboard. If a call comes back\n `SCOPE_NOT_ALLOWED` or `TITLE_NOT_ALLOWED`, the token is fine — that permission or that Title\n simply was not granted; ask the publisher to re-authorize rather than retrying.\n\n Configuring a **fresh (empty) Title** so a game can actually run against it (currencies → game\n loop → bots) is its own checklist: see **idosgames-title-bootstrap**.\n\nRule of thumb: **game CODE → `@idosgames/mcp`; a Title's live config DATA and generated ASSETS → the\nbackend `v2/mcp` server.** Scaffolding a project and configuring/populating the Title it runs as are\ntwo different jobs on two different servers — connect the one that matches the task (or both).\n",
|
|
3
|
+
"description": "Start a new game or app on the iDosGames composable-module architecture: scaffold the base (sign-in + the lobby on the host shell) and plug in modules — the systems (shop, heroes, leaderboards…) and the games (board-game, idle-rpg, voxelcraft, mini-game, …). Use this whenever a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and idosgames-compose-modules (merging several) and idosgames-project-structure (where code goes as the project grows). If pulling modules over MCP, use the @idosgames/mcp tools get_host_scaffold / get_module / get_manifest.",
|
|
4
|
+
"content": "---\nname: idosgames-getting-started\ndescription: >-\n Start a new game or app on the iDosGames composable-module architecture: scaffold the base (sign-in\n + the lobby on the host shell) and plug in modules — the systems (shop, heroes, leaderboards…) and\n the games (board-game, idle-rpg, voxelcraft, mini-game, …). Use this whenever\n a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a\n project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or\n src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and\n idosgames-compose-modules (merging several) and idosgames-project-structure (where code goes as\n the project grows). If pulling modules over MCP, use the @idosgames/mcp\n tools get_host_scaffold / get_module / get_manifest.\n---\n\n# Getting started (iDosGames composable modules)\n\nAn iDosGames project is **one host shell + N feature modules**. The host owns everything that exists\nonce — the SDK client, login, the React root, the screen, a module registry, and a Mode Router that\nswitches between modules. A module is a library (a game or app) that plugs into the host; it never\ncreates the client, logs in, or owns the root.\n\n## Runtime packages (npm)\n\n- `@idosgames/core` — the SDK client (`createIDosGamesClient`): auth, currency, store, characters,\n blockchain, ~28 services. See the per-service skills (currency-system, store-system, …).\n- `@idosgames/module-sdk` — the module contract types (`Module`, `ModuleContext`, `EngineScene`,\n `UiPanel`). See idosgames-module-contract.\n- `@idosgames/react` — shared React glue (`IDosGamesProvider`, `useIDosGamesClient`, `useUserState`,\n `StatusProvider`, `createControllerContext`).\n- `@idosgames/app-shell` — the host runtime (`mountHost`, the Mode Router, the module registry).\n- `@idosgames/wallet` — optional wallet bridge (EVM/Solana) for on-chain deposits/withdrawals.\n\nInstall the exact versions from `get_manifest` (MCP) or the registry `index.json` `runtimePackages`.\n\n## Project shape\n\n```\nIDOS.md # the project guide every agent reads first (short, evergreen)\nAGENTS.md, CLAUDE.md # pointers to IDOS.md for external tools\ndocs/feature-history/ # one file per game system + README.md index (the project's memory)\nindex.html # mounts #app\nsrc/main.tsx # creates ONE client and calls mountHost({container, client, modules})\nsrc/modules.ts # the registry: export const modules: Module[] = [ … ] — imports + array only\nsrc/modules/{id}/ # each module's source + its module.meta.json (from get_module)\n```\n\nPlatform-created projects also carry `idos.modules.lock.json` (which modules came from the catalog,\nwith file fingerprints) — it is platform-owned; don't edit it. Where new code goes as the game grows,\nand how the project documents itself, is **idosgames-project-structure**.\n\n`src/main.tsx` (host-owned) is the only place that creates the client — but it does **not** sign in.\n`mountHost` owns sign-in: it replays a previous session (`autoLogin`) and renders the login screen\nwhen there is nothing to replay. Calling a `login*` method here skips that screen for good, taking\n\"switch account\" and wallet sign-in with it:\n\n```ts\nconst client = createIDosGamesClient({ titleID, buildKey, throttleMs: 0 });\n// Do NOT log in here — the host does it.\nmountHost({\n container: app,\n client,\n modules, // from ./modules\n renderLogin, // optional: your own screen; omitted = a plain guest-only default\n});\n```\n\nWhether a returning player is signed back in silently is the login screen's business, not the\nhost's: it calls `client.auth.setRememberSession(remember)` before a `login*` method. See the\nauthentication skill.\n\n`src/modules.ts` registers what the project composes:\n\n```ts\nimport type { Module } from \"@idosgames/module-sdk\";\nimport { boardGameModule } from \"./modules/board-game\";\nexport const modules: Module[] = [boardGameModule];\n```\n\n## Steps to scaffold\n\n1. **Base** — write the host scaffold to the project root (`get_host_scaffold`, or copy\n `templates/base`): sign-in and the lobby in `src/base/`, `src/app.config.ts` (start in the lobby or\n a game), an empty `src/modules.ts` → an empty lobby.\n2. **Bind the Title** — open `src/idos.title.ts` and set `IDOS_TITLE_ID` to the game's canonical\n Title id (and `IDOS_BUILD_KEY` if the title enforces one). This file is the project's single\n centralized identity — it is the highest-priority source and the only channel a packaged mobile\n build, iframe embed, or shared link has. On platform-created projects the platform generates it;\n on a manually scaffolded project **you fill it yourself**. Do NOT bind the title via `.env.local`\n (`VITE_IDOS_TITLE_ID`) — that is a local-dev fallback for the raw template only, and it does not\n travel with the code.\n3. **Pick modules** — `list_modules` / `search`, then `get_module {id}` for each. Write its files\n into `src/modules/{id}/`. The base's systems are the modules with `inBase: true` (profile,\n inventory, invite, friends, quests, mailbox); add the game(s) with the modules each game `uses`, and any\n other system (shop, VIP, leaderboards…) only when the app needs it.\n4. **Register** — for each module add `import { {camelCase(id)}Module } from \"./modules/{id}\"` and\n push it into the `modules` array (e.g. `board-game` → `boardGameModule`). Then add the module to\n the \"Installed modules\" block of `IDOS.md` (one line: ``- `board-game` (0.1.0) — src/modules/board-game/``)\n — on platform projects the platform maintains that block itself.\n5. **Install deps** — the union of `runtimePackages` + each module's `dependencies`. Modules bring\n their own engine (three, phaser); the host brings react/react-dom/app-shell.\n6. **Run** — sign-in → the lobby: the systems' screens are its tabs (a tab hides itself while the\n title has nothing for it), every game gets a \"Play\" button, and a small button over a game returns\n to the lobby. The lobby draws the balances and the wallet; games hide their own copies by\n themselves (`sharedUi` roles).\n\nTo combine multiple genres into one game, see **idosgames-compose-modules**. To write or edit a\nmodule, see **idosgames-module-contract**.\n\n## Where state lives (decide this before writing the first save)\n\nThe project is client-side code in the player's browser. `localStorage`, module fields, and React\nstate are **not storage** — nothing there survives a device change, and nothing there is trusted.\n\n1. **A dedicated module owns it?** Use that module. Currencies, inventory, quests, characters,\n leaderboards, store purchases each have a service that enforces the rules server-side.\n2. **Otherwise, per-player data → `client.userCustomData`** — buckets `Private`/`Public` are\n client-writable (settings, cosmetics), `ReadOnly`/`Internal` are server-only. Anything a player\n could cheat by editing goes in the server-only buckets. See **user-custom-data**.\n3. **Shared by all players → `client.titleCustomData`** (event state, global counters, server\n thresholds, feature toggles). Read-only for clients. See **title-custom-data**.\n4. **Writing any of the server-only data, or any rule the player must not be able to fake** →\n a CloudCode handler, called with `client.cloudCode.execute(...)`. See **cloud-code**.\n\n## Two MCP surfaces: game CODE vs. a Title's live DATA\n\nThe platform exposes **two independent MCP servers** — don't confuse them:\n\n- **`@idosgames/mcp`** (this one) serves the **CODE registry**. Use it to WRITE a game's source:\n `get_host_scaffold`, `list_modules` / `search` / `get_module`, `get_manifest`, `list_skills` /\n `get_skill`. Transport: stdio (`npx -y @idosgames/mcp`). Its registry is bundled offline; to use the\n hosted copy set `IDOSGAMES_REGISTRY_URL=https://cloud.idosgames.com/drive/registry/latest` — a **base**\n the loader appends `/index.json`, `/modules/{id}.json`, etc. to (the base itself is not fetchable on\n R2; open `.../latest/index.json` to browse the catalog). Read-only, no auth. It never reads or changes\n a live Title's data.\n- **The Title-configuration MCP** (a separate backend server) owns a **live Title's DATA**. Use it to\n read/write the Title's `TitlePublicConfiguration` (`get_<field>` / `save_<field>`, or the whole\n model) and to generate assets (`generate_image` / `generate_audio` / `generate_text` /\n `generate_three_d` / `generate_video`). Transport: HTTP JSON-RPC at\n `POST https://site.idosgames.com/api/v2/mcp`; every tool call takes a `title_id` argument.\n Authorization is **OAuth 2.1** — there is no API key and nothing to paste. Connect it as a plain\n HTTP MCP server with **no headers**: your client gets a `401`, discovers the authorization\n server, registers itself, and opens a browser where the publisher picks which Titles and which\n permissions to grant. The token lives in your client's own credential store, so committed config\n holds only the URL:\n\n ```json\n {\n \"mcpServers\": {\n \"idosgames-title\": {\n \"type\": \"http\",\n \"url\": \"https://site.idosgames.com/api/v2/mcp\"\n }\n }\n }\n ```\n\n Permissions the publisher can grant: `config:read`, `config:write`, `cloudcode:write`,\n `ai:generate`. A grant is scoped to the Titles ticked on the consent screen, and the publisher\n can revoke it any time from **Connected apps** in the dashboard. If a call comes back\n `SCOPE_NOT_ALLOWED` or `TITLE_NOT_ALLOWED`, the token is fine — that permission or that Title\n simply was not granted; ask the publisher to re-authorize rather than retrying.\n\n Configuring a **fresh (empty) Title** so a game can actually run against it (currencies → game\n loop → bots) is its own checklist: see **idosgames-title-bootstrap**.\n\nRule of thumb: **game CODE → `@idosgames/mcp`; a Title's live config DATA and generated ASSETS → the\nbackend `v2/mcp` server.** Scaffolding a project and configuring/populating the Title it runs as are\ntwo different jobs on two different servers — connect the one that matches the task (or both).\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "idosgames-module-contract",
|
|
3
|
-
"description": "Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk: the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route registration, and the shared controller-box bridge between an imperative scene and React panels. Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg, voxelcraft, game
|
|
4
|
-
"content": "---\nname: idosgames-module-contract\ndescription: >-\n Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk:\n the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route\n registration, and the shared controller-box bridge between an imperative scene and React panels.\n Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg,\n voxelcraft, game-hud, workshop), or asks about defineModule, registerScene/registerPanel/registerRoute,\n activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic,\n ctx.content / ContentTypeHandler (letting the Workshop publish and open the game's content),\n ctx.navigate, the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention.\n---\n\n# The module contract (@idosgames/module-sdk)\n\nA module is a manifest the host installs once. It exports `{camelCase(id)}Module` from its\n`index.ts` (e.g. `board-game` → `boardGameModule`, `idle-rpg` → `idleRpgModule`) — the host seeder\nderives the import name from the id, so this convention is required.\n\n```ts\nimport { defineModule } from \"@idosgames/module-sdk\";\n\nexport const boardGameModule = defineModule({\n id: \"board-game\",\n meta: { name: \"Board Game\", type: \"game\", genre: \"board\", engine: \"three\" }, // engine: three|phaser|dom\n setup(ctx) {\n // ctx.client (shared, authed) · ctx.events (typed cross-module bus) · ctx.sharedUi · ctx.surface\n ctx.registerScene(createScene(box)); // rendered engine scene (optional)\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: RootPanel }); // React UI (optional)\n ctx.registerRoute({ id: \"board-game\", label: \"Board\", icon: \"🎲\" }); // nav/mode entry\n },\n});\n```\n\nOptional static field: `sharedUi: { provides?: SharedUiRole[]; requires?: SharedUiRole[] }` — see\n\"Shared chrome\" below.\n\n`meta.type` is `game | app | ai-app`; `meta.engine` is `three | phaser | dom` (`dom` = no renderer —\na pure React/DOM app module). `setup` is called ONCE with `ctx: ModuleContext`.\n\n## EngineScene (the rendered part)\n\nA scene mounts into a bare `HTMLElement` (framework-free — this is why a vanilla Three game like\nvoxelcraft fits). The host's Mode Router drives it; only the active mode runs.\n\n```ts\nconst scene: EngineScene = {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext) {\n controller = new Controller(ctx.host);\n box.set(controller);\n },\n activate() {\n controller?.setRunning(true);\n }, // became the active mode → resume RAF\n suspend() {\n controller?.setRunning(false);\n }, // hidden → stop RAF (invariant: only active ticks)\n destroy() {\n controller?.destroy();\n }, // permanent teardown\n};\n```\n\n`mount` takes a context object (not a bare element) so it can grow — e.g. a host-shared renderer in\nthe composition era — without breaking the contract. A scene MUST stop its RAF on `suspend`.\n\nA module that registers a scene MUST also publish its debug surface — `ctx.exposeToAgent({ state,\nactions, describeActions })` — and must NOT gate controls on Pointer Lock (unavailable in the\npreview's cross-origin iframe). Nothing inside a `<canvas>` is observable from the DOM, so without\nthe surface neither the AI Coder nor a human reviewer can tell what the game is doing. See the\n`idosgames-agent-debug-surface` skill.\n\n### `capture()` — the frame for \"Take a shot and share\"\n\nidosgames.com has a **Shot** button under the game: it asks the game for one frame, puts the\nplatform's branding and a QR code on it and offers to post it. A scene answers through the optional\n`capture?(): Promise<Blob | null>`. The host (`@idosgames/app-shell`) wires the request itself —\na module only has to return a picture of its active scene.\n\n**Implement it in any scene that renders to a canvas.** Without it the host falls back to reading\nthe `<canvas>` from outside, and for WebGL that is a trap: a renderer created WITHOUT\n`preserveDrawingBuffer` (the default, and the right default — the flag costs memory and a copy every\nframe) has already cleared its buffer by the time anyone reads it, so `toBlob()` returns a **fully\nblack** image and reports nothing. The host rejects single-colour frames, so the player gets the\ngame's cover instead of their moment.\n\nRender and read in the SAME tick:\n\n```ts\n// three.js\ncapture() {\n return new Promise((resolve) => {\n renderer.render(scene, camera); // draw now…\n renderer.domElement.toBlob(resolve, \"image/png\"); // …and read before the buffer is cleared\n });\n},\n\n// Phaser — use its own snapshot, it handles the timing\ncapture() {\n return new Promise((resolve) => {\n setTimeout(() => resolve(null), 1500); // no frame came (context lost) → answer \"no\", don't hang\n game.loop.wake(); // a sleeping loop never draws again → snapshot never fires\n game.renderer.snapshot((image) => {\n if (!(image instanceof HTMLImageElement)) return resolve(null);\n const c = document.createElement(\"canvas\");\n c.width = image.naturalWidth || image.width;\n c.height = image.naturalHeight || image.height;\n c.getContext(\"2d\")?.drawImage(image, 0, 0);\n c.toBlob(resolve, \"image/png\");\n });\n });\n},\n```\n\nAlways answer — with a Blob or `null`. A `capture()` that never settles makes the site wait out its\ntimeout before it falls back, and the player stares at a spinner.\n\nReturn `null` when there is nothing worth showing (loading, no scene yet) — the site then uses the\ncover. Only the scene is captured, not the React panels over it: a shot for a feed reads better\nwithout the HUD. DOM-only modules (no canvas) simply omit `capture`. Working references:\n`modules/board-game` and `modules/voxelcraft` (three), `modules/idle-rpg` (Phaser).\n\n## UiPanel (the React part)\n\nPanels are React components the host renders inside its provider stack (client + status already\nprovided). Use `@idosgames/react` hooks (`useIDosGamesClient`, `useUserState`) — do NOT re-create\nproviders.\n\n- `slot`: `hud | sidebar | overlay | modal`.\n- `activeOnly` (default true): show only while this module's mode is active. Set `false` for shared\n chrome that stays across every mode (e.g. a persistent HUD).\n- `overlay`/`sidebar` layers sit between the shared HUD and the nav (the host measures both), so a\n panel positioned `absolute` inside its layer never ends up under them. Things drawn outside the\n layers use `var(--idos-safe-top, 0px)` / `var(--idos-safe-bottom, 0px)`.\n- A `hud` panel's wrapper is `display: contents`: the panel is a direct flex child of the HUD row\n (can `flex: 1`) and must set `pointer-events: auto` only on its controls.\n\n## Shared chrome (`sharedUi`)\n\nRoles: `currency-bar` (virtual-currency balances), `wallet` (crypto wallet entry — `LazyWalletPanel`),\n`status` (the `useStatus()` line), `account` (Log out + player ID). A module that draws one for every\nmode declares `sharedUi: { provides: [...] }` on the module object — statically, so the host resolves\nowners before any `setup()` and the answer never depends on module order.\n\n**Rule for templates: yield every role you draw yourself.** Read it once in `setup()` and close over\nthe answer (it is fixed for the session):\n\n```ts\nsetup(ctx) {\n const chrome = {\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n balances: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n };\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: makeRootPanel(box, chrome) });\n}\n```\n\nHide only the SHARED pieces — genre UI (the board's stage/tile/cycle chips) always stays. Never\nwrap your UI in your own `<StatusProvider>`: the host provides ONE, and a nested one would swallow\nyour messages so the shared status line never sees them. A module that needs a role but does not\ndraw it declares `sharedUi: { requires: [\"currency-bar\"] }`. `ctx.sharedUi.ownerOf(role)` names the\nprovider (or `null`). A full-screen mode (its own bottom tab bar) that shows the player ID and\n\"Log out\" in its own header declares `sharedUi: { drawsOwn: [\"account\"] }` and draws them only while\n`shouldDraw(\"account\")`: the host then hides its floating bottom-left copy while that mode is on\nscreen (it would cover the tab bar) and keeps it in the other modes. `drawsOwn` takes no role —\nwith game-hud installed the HUD still draws the account and the mode hides its copy.\n\n## Events (`ctx.events`)\n\nTyped topic tokens — `defineTopic(\"<your-id>:<event>@1\", shape({ … }))` from\n`@idosgames/module-sdk`; emit only your own namespace; to listen to another module, copy its topic\nand payload descriptor from the catalog into your own `defineTopic` (never import it); declare both\nin `module.meta.json` (`events.emits` / `events.listens`). **Subscribe in `setup()`**, not in a panel\neffect — `activeOnly` panels unmount with their mode and miss events; store what arrives in a small\nstore the panel reads. Full rules and examples: **idosgames-compose-modules** (\"Signals between\nmodules\"). The string overloads (`emit(\"x\", …)`) are deprecated.\n\n## Content types (`ctx.content`) and switching modes (`ctx.navigate`)\n\nA game whose players make things (worlds, levels, skins) registers a **content type handler** so the\nshared Workshop module can publish and open them — the Workshop never imports the game:\n\n```ts\nsetup(ctx) {\n ctx.content.registerType({\n type: \"voxelcraft.world\", // = a key of Workshop.ContentTypes in the title config\n label: \"VoxelCraft world\",\n icon: \"⛏️\",\n modeId: \"voxelcraft\", // route to switch to after open()\n async listLocal() { return saves.map((s) => ({ id: s.id, name: s.name, updatedAt: s.at })); },\n async capture(localId) { // roles/MIME types must fit the title's content type config\n return { files: [{ role: \"main\", contentType: \"application/json\", data: json }],\n thumbnail: { contentType: \"image/webp\", data: webp }, suggestedTitle: name };\n },\n async open(content) { importSave(content.files[0].data); },\n });\n}\n```\n\nHandlers registered in `setup()` are removed with the module; `registerType` returns an unregister\nfunction for anything dynamic. `ctx.navigate(modeId)` switches the host to another module's route\n(the Workshop uses it to jump into the game after `open`); an unknown id is ignored. Details:\n**workshop-system**.\n\n## Bridging scene ↔ panel\n\nThe scene creates its controller at `mount` (needs the canvas), but panels render before that. Share\nit with a one-slot observable and read it with `useSyncExternalStore`:\n\n```ts\nexport function createControllerBox<T>() {\n /* get/set/subscribe */\n}\n// panel: const controller = useSyncExternalStore(box.subscribe, box.get); if (!controller) return <Loading/>;\n```\n\nFor the module's controller React context use the shared factory instead of hand-writing it:\n\n```ts\nexport const [BoardControllerProvider, useBoardController] =\n createControllerContext<BoardController>(\"BoardController\"); // from @idosgames/react\n```\n\n## Layout and boundaries\n\nA module is one folder, `src/modules/<id>/`. It imports only its own files, the game's shared code\nin `src/shared/` and npm packages — never another module's files or host files. Talk to other\nmodules through the shared `ctx.client` (durable state) and `ctx.events` (live signals, typed\ntopics `<module-id>:<event>@<major>` declared in `module.meta.json`). A module shipped in the catalog is fully self-contained — it never uses\n`src/shared/`, so it installs into any game; a game's own module may use it, and its shared code is\ncopied into it when it is published for other creators.\n\n```\nsrc/modules/<id>/\n index.ts export { <camelCaseId>Module } from \"./module\";\n module.ts defineModule({ … })\n module.meta.json manifest: type (template|feature), summary, provides, tags, version, author\n components/ game/ data/ react/ — as needed\n```\n\nEvery module carries its own `module.meta.json` (the `ModuleManifest` shape), including a game's own\nmodules. The full standard — where a new feature goes, file size, documentation — is\n**idosgames-project-structure**.\n\n## Dependencies\n\nDeclare only the module's UNIQUE deps (e.g. `phaser` for idle-rpg, `three` for board/voxel) plus the\nshared baseline (react, @idosgames/*). The platform pins shared libs identically across modules; two\nmodules must not request different versions of one package (the build allowlist rejects it).\n\nStudy a real module via `get_module {id}` (MCP) before writing a new one.\n",
|
|
3
|
+
"description": "Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk: the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route registration, and the shared controller-box bridge between an imperative scene and React panels. Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg, voxelcraft, mini-game, store, character, workshop…), or asks about defineModule, registerScene/registerPanel/registerRoute, ctx.features (register / open / setBadge / setAvailable), ctx.modes, activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic, ctx.content / ContentTypeHandler (letting the Workshop publish and open the game's content), ctx.multiplayer / registerGame (playing online through the lobby's rooms system), ctx.navigate, the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention.",
|
|
4
|
+
"content": "---\nname: idosgames-module-contract\ndescription: >-\n Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk:\n the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route\n registration, and the shared controller-box bridge between an imperative scene and React panels.\n Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg,\n voxelcraft, mini-game, store, character, workshop…), or asks about defineModule,\n registerScene/registerPanel/registerRoute, ctx.features (register / open / setBadge / setAvailable),\n ctx.modes,\n activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic,\n ctx.content / ContentTypeHandler (letting the Workshop publish and open the game's content),\n ctx.multiplayer / registerGame (playing online through the lobby's rooms system),\n ctx.navigate, the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention.\n---\n\n# The module contract (@idosgames/module-sdk)\n\nA module is a manifest the host installs once. It exports `{camelCase(id)}Module` from its\n`index.ts` (e.g. `board-game` → `boardGameModule`, `idle-rpg` → `idleRpgModule`) — the host seeder\nderives the import name from the id, so this convention is required.\n\n```ts\nimport { defineModule } from \"@idosgames/module-sdk\";\n\nexport const boardGameModule = defineModule({\n id: \"board-game\",\n meta: { name: \"Board Game\", type: \"game\", genre: \"board\", engine: \"three\" }, // engine: three|phaser|dom\n setup(ctx) {\n // ctx.client (shared, authed) · ctx.events (typed cross-module bus) · ctx.sharedUi · ctx.surface\n ctx.registerScene(createScene(box)); // rendered engine scene (optional)\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: RootPanel }); // React UI (optional)\n ctx.registerRoute({ id: \"board-game\", label: \"Board\", icon: \"🎲\" }); // a mode → the lobby's \"Play\"\n },\n});\n```\n\nTwo kinds of module plug into the base (`src/base/` — sign-in and the lobby):\n\n- a **game** (`meta.type: \"game\"`) registers a route: the lobby shows a \"Play\" button for it, and\n over it the base draws only a small \"To the lobby\" button;\n- a **system** (`meta.type: \"app\"`) registers a feature — its screen becomes a lobby tab:\n `ctx.features.register({ id, label, icon, group, order, primary?, available?, Screen })`. A tab\n that depends on title data registers with `available: false` and calls\n `ctx.features.setAvailable(id, ok, reason)` once it knows (details: **idosgames-ui-kit**).\n\n`ctx.modes.list()` / `current()` / `subscribe()` — every mode (id, label, icon, module, module type),\nfilled once all modules are installed (read it at render time, not inside `setup()`).\n`ctx.navigate(id)` switches (`\"lobby\"` is the base); called inside `setup()` it picks the mode the app\nstarts in. `ctx.features.open(id, args)` from a game (the lobby not on screen) switches to the lobby\nand opens that tab there.\n\n`ctx.multiplayer` — a game that plays online registers itself (`registerGame({ mode, topology,\nchatMode, maxPlayers, joinInProgress, settings, start(room) })`) and takes the room the lobby's\nrooms system hands it on \"Play\"; it never creates or joins rooms (skill **multiplayer-realtime**).\n\nOptional static field: `sharedUi: { provides?: SharedUiRole[]; requires?: SharedUiRole[] }` — see\n\"Shared chrome\" below.\n\n`meta.type` is `game | app | ai-app`; `meta.engine` is `three | phaser | dom` (`dom` = no renderer —\na pure React/DOM app module). `setup` is called ONCE with `ctx: ModuleContext`.\n\n## EngineScene (the rendered part)\n\nA scene mounts into a bare `HTMLElement` (framework-free — this is why a vanilla Three game like\nvoxelcraft fits). The host's Mode Router drives it; only the active mode runs.\n\n```ts\nconst scene: EngineScene = {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext) {\n controller = new Controller(ctx.host);\n box.set(controller);\n },\n activate() {\n controller?.setRunning(true);\n }, // became the active mode → resume RAF\n suspend() {\n controller?.setRunning(false);\n }, // hidden → stop RAF (invariant: only active ticks)\n destroy() {\n controller?.destroy();\n }, // permanent teardown\n};\n```\n\n`mount` takes a context object (not a bare element) so it can grow — e.g. a host-shared renderer in\nthe composition era — without breaking the contract. A scene MUST stop its RAF on `suspend`.\n\nA module that registers a scene MUST also publish its debug surface — `ctx.exposeToAgent({ state,\nactions, describeActions })` — and must NOT gate controls on Pointer Lock (unavailable in the\npreview's cross-origin iframe). Nothing inside a `<canvas>` is observable from the DOM, so without\nthe surface neither the AI Coder nor a human reviewer can tell what the game is doing. See the\n`idosgames-agent-debug-surface` skill.\n\n### `capture()` — the frame for \"Take a shot and share\"\n\nidosgames.com has a **Shot** button under the game: it asks the game for one frame, puts the\nplatform's branding and a QR code on it and offers to post it. A scene answers through the optional\n`capture?(): Promise<Blob | null>`. The host (`@idosgames/app-shell`) wires the request itself —\na module only has to return a picture of its active scene.\n\n**Implement it in any scene that renders to a canvas.** Without it the host falls back to reading\nthe `<canvas>` from outside, and for WebGL that is a trap: a renderer created WITHOUT\n`preserveDrawingBuffer` (the default, and the right default — the flag costs memory and a copy every\nframe) has already cleared its buffer by the time anyone reads it, so `toBlob()` returns a **fully\nblack** image and reports nothing. The host rejects single-colour frames, so the player gets the\ngame's cover instead of their moment.\n\nRender and read in the SAME tick:\n\n```ts\n// three.js\ncapture() {\n return new Promise((resolve) => {\n renderer.render(scene, camera); // draw now…\n renderer.domElement.toBlob(resolve, \"image/png\"); // …and read before the buffer is cleared\n });\n},\n\n// Phaser — use its own snapshot, it handles the timing\ncapture() {\n return new Promise((resolve) => {\n setTimeout(() => resolve(null), 1500); // no frame came (context lost) → answer \"no\", don't hang\n game.loop.wake(); // a sleeping loop never draws again → snapshot never fires\n game.renderer.snapshot((image) => {\n if (!(image instanceof HTMLImageElement)) return resolve(null);\n const c = document.createElement(\"canvas\");\n c.width = image.naturalWidth || image.width;\n c.height = image.naturalHeight || image.height;\n c.getContext(\"2d\")?.drawImage(image, 0, 0);\n c.toBlob(resolve, \"image/png\");\n });\n });\n},\n```\n\nAlways answer — with a Blob or `null`. A `capture()` that never settles makes the site wait out its\ntimeout before it falls back, and the player stares at a spinner.\n\nReturn `null` when there is nothing worth showing (loading, no scene yet) — the site then uses the\ncover. Only the scene is captured, not the React panels over it: a shot for a feed reads better\nwithout the HUD. DOM-only modules (no canvas) simply omit `capture`. Working references:\n`modules/board-game` and `modules/voxelcraft` (three), `modules/idle-rpg` (Phaser).\n\n## UiPanel (the React part)\n\nPanels are React components the host renders inside its provider stack (client + status already\nprovided). Use `@idosgames/react` hooks (`useIDosGamesClient`, `useUserState`) — do NOT re-create\nproviders.\n\n- `slot`: `hud | sidebar | overlay | modal`.\n- `activeOnly` (default true): show only while this module's mode is active. Set `false` for shared\n chrome that stays across every mode (e.g. a persistent HUD).\n- `overlay`/`sidebar` layers sit between the shared HUD and the nav (the host measures both), so a\n panel positioned `absolute` inside its layer never ends up under them. Things drawn outside the\n layers use `var(--idos-safe-top, 0px)` / `var(--idos-safe-bottom, 0px)`.\n- A `hud` panel's wrapper is `display: contents`: the panel is a direct flex child of the HUD row\n (can `flex: 1`) and must set `pointer-events: auto` only on its controls.\n\n## Shared chrome (`sharedUi`)\n\nRoles: `currency-bar` (virtual-currency balances), `wallet` (crypto wallet entry — `LazyWalletPanel`),\n`status` (the `useStatus()` line), `account` (Log out + player ID), `menu` (the menu of features),\n`modes` (the switch between modes). The base takes `currency-bar`, `wallet`, `account`, `menu` and\n`modes` — its lobby draws them; `status` stays with each game. A module that draws one for every\nmode declares `sharedUi: { provides: [...] }` on the module object — statically, so the host resolves\nowners before any `setup()` and the answer never depends on module order.\n\n**Rule for templates: yield every role you draw yourself.** Read it once in `setup()` and close over\nthe answer (it is fixed for the session):\n\n```ts\nsetup(ctx) {\n const chrome = {\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n balances: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n };\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: makeRootPanel(box, chrome) });\n}\n```\n\nHide only the SHARED pieces — genre UI (the board's stage/tile/cycle chips) always stays. Never\nwrap your UI in your own `<StatusProvider>`: the host provides ONE, and a nested one would swallow\nyour messages so the shared status line never sees them. A module that needs a role but does not\ndraw it declares `sharedUi: { requires: [\"currency-bar\"] }`. `ctx.sharedUi.ownerOf(role)` names the\nprovider (or `null`). A full-screen mode (its own bottom tab bar) that shows the player ID and\n\"Log out\" in its own header declares `sharedUi: { drawsOwn: [\"account\"] }` and draws them only while\n`shouldDraw(\"account\")`: the host then hides its floating bottom-left copy while that mode is on\nscreen (it would cover the tab bar) and keeps it in the other modes. `drawsOwn` takes no role —\nwith the base installed the lobby draws the account and the mode hides its copy.\n\n## Events (`ctx.events`)\n\nTyped topic tokens — `defineTopic(\"<your-id>:<event>@1\", shape({ … }))` from\n`@idosgames/module-sdk`; emit only your own namespace; to listen to another module, copy its topic\nand payload descriptor from the catalog into your own `defineTopic` (never import it); declare both\nin `module.meta.json` (`events.emits` / `events.listens`). **Subscribe in `setup()`**, not in a panel\neffect — `activeOnly` panels unmount with their mode and miss events; store what arrives in a small\nstore the panel reads. Full rules and examples: **idosgames-compose-modules** (\"Signals between\nmodules\"). The string overloads (`emit(\"x\", …)`) are deprecated.\n\n## Content types (`ctx.content`) and switching modes (`ctx.navigate`)\n\nA game whose players make things (worlds, levels, skins) registers a **content type handler** so the\nshared Workshop module can publish and open them — the Workshop never imports the game:\n\n```ts\nsetup(ctx) {\n ctx.content.registerType({\n type: \"voxelcraft.world\", // = a key of Workshop.ContentTypes in the title config\n label: \"VoxelCraft world\",\n icon: \"⛏️\",\n modeId: \"voxelcraft\", // route to switch to after open()\n async listLocal() { return saves.map((s) => ({ id: s.id, name: s.name, updatedAt: s.at })); },\n async capture(localId) { // roles/MIME types must fit the title's content type config\n return { files: [{ role: \"main\", contentType: \"application/json\", data: json }],\n thumbnail: { contentType: \"image/webp\", data: webp }, suggestedTitle: name };\n },\n async open(content) { importSave(content.files[0].data); },\n });\n}\n```\n\nHandlers registered in `setup()` are removed with the module; `registerType` returns an unregister\nfunction for anything dynamic. `ctx.navigate(modeId)` switches the host to another module's route\n(the Workshop uses it to jump into the game after `open`); an unknown id is ignored. Details:\n**workshop-system**.\n\n## Bridging scene ↔ panel\n\nThe scene creates its controller at `mount` (needs the canvas), but panels render before that. Share\nit with a one-slot observable and read it with `useSyncExternalStore`:\n\n```ts\nexport function createControllerBox<T>() {\n /* get/set/subscribe */\n}\n// panel: const controller = useSyncExternalStore(box.subscribe, box.get); if (!controller) return <Loading/>;\n```\n\nFor the module's controller React context use the shared factory instead of hand-writing it:\n\n```ts\nexport const [BoardControllerProvider, useBoardController] =\n createControllerContext<BoardController>(\"BoardController\"); // from @idosgames/react\n```\n\n## Layout and boundaries\n\nA module is one folder, `src/modules/<id>/`. It imports only its own files, the game's shared code\nin `src/shared/` and npm packages — never another module's files or host files. Talk to other\nmodules through the shared `ctx.client` (durable state) and `ctx.events` (live signals, typed\ntopics `<module-id>:<event>@<major>` declared in `module.meta.json`). A module shipped in the catalog is fully self-contained — it never uses\n`src/shared/`, so it installs into any game; a game's own module may use it, and its shared code is\ncopied into it when it is published for other creators.\n\n```\nsrc/modules/<id>/\n index.ts export { <camelCaseId>Module } from \"./module\";\n module.ts defineModule({ … })\n module.meta.json manifest: type (template|feature), summary, provides, tags, version, author\n components/ game/ data/ react/ — as needed\n```\n\nEvery module carries its own `module.meta.json` (the `ModuleManifest` shape), including a game's own\nmodules. The full standard — where a new feature goes, file size, documentation — is\n**idosgames-project-structure**.\n\n## Dependencies\n\nDeclare only the module's UNIQUE deps (e.g. `phaser` for idle-rpg, `three` for board/voxel) plus the\nshared baseline (react, @idosgames/*). The platform pins shared libs identically across modules; two\nmodules must not request different versions of one package (the build allowlist rejects it).\n\nStudy a real module via `get_module {id}` (MCP) before writing a new one.\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|
|
@@ -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
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "idosgames-ui-kit",
|
|
3
|
-
"description": "Build and restyle game screens with the iDosGames UI kit (@idosgames/react/ui) and the ready
|
|
4
|
-
"content": "---\nname: idosgames-ui-kit\ndescription: >-\n Build and restyle game screens with the iDosGames UI kit (@idosgames/react/ui) and the ready\n screen modules (*-ui): change colours, animations and sounds in ONE file (src/ui.config.ts),\n install a shop / heroes / inventory / leaderboard / rewards / craft / marketplace / offers /\n events / season / friends / chat / invite / arena / profile / tutorials screen instead of writing\n it, open another module's screen with ctx.features.open, register a new screen with\n ctx.features.register, split a dense screen into popups (Popup, ConfirmPopup, bottom sheet on a\n phone), play UI sounds, celebrate a reward (icons fly to the balance counters), mark elements for\n tutorial hints (data-tutorial-anchor), and read the player's sound / motion prefs. Use this\n whenever the user asks to change how the game's interface looks, sounds or animates, to add or\n build a game screen or menu, a popup, a reward animation, a bottom navigation, a \"More\" menu, a\n settings screen, or touches @idosgames/react/ui, defineUiConfig, ui.config.ts, ctx.features,\n FeatureScreenProps, useCelebrate, usePlayerPrefs, Popup, Tabs or ResourceList.\n---\n\n# The UI kit and the screen modules\n\nThe game's interface is built from **screen modules** on one **UI kit**:\n\n- `@idosgames/react/ui` — the kit: components (buttons, tabs, popups, resource lists, item tiles,\n progress bars, timers), the reward popup, toasts, UI sounds (synthesized, no files), animations,\n and the player's own sound / motion prefs.\n- `*-ui` modules — ready screens of the platform's systems, each a `feature` module:\n\n| Module | Feature id | Screen |\n| ---------------- | ------------- | -------------------------------------------------------------- |\n| `store-ui` | `store` | shop: stores as tabs, offers, purchase popup |\n| `character-ui` | `character` | heroes: gear slots, \"Equip best\", stats, ranks, skins (popups) |\n| `inventory-ui` | `inventory` | items by rarity, item card, lootbox opening |\n| `leaderboard-ui` | `leaderboard` | tables, my place, rank rewards, last cycle's prize |\n| `rewards-ui` | `rewards` | daily calendar, quests, points track |\n| `craft-ui` | `craft` | recipes, contract popup |\n| `marketplace-ui` | `marketplace` | lots, auctions, selling, trades |\n| `deal-offer-ui` | `deals` | special offers, auto-popup of a new deal |\n| `events-ui` | `events` | limited-time events, milestones, VIP prize |\n| `season-ui` | `season` | season tiers, VIP prize |\n| `friends-ui` | `friends` | friends, requests, suggestions, news |\n| `chat-ui` | `chat` | channels, private chats |\n| `referral-ui` | `invite` | own code and link, friend's code, rewards |\n| `pvp-ui` | `pvp` | duels, strategy, fight replay |\n| `profile-ui` | `profile` | name, sound, language, consent, delete account |\n| `tutorial-ui` | `tutorials` | hint overlay + replay list |\n\n**Need one of these? Install the module — don't write the screen.** Every screen follows the\nserver's rules and ships demo title settings (`title-seed.json`).\n\n## Change the look, animations and sounds — only in `src/ui.config.ts`\n\n```ts\nimport { defineUiConfig } from \"@idosgames/react/ui\";\n\nexport default defineUiConfig({\n theme: {\n bgTop: \"#10263f\",\n panel: \"#1b3a5c\",\n gold: \"#ffcc33\",\n radius: 12,\n rarity: { Mythic: \"#ff3df5\" },\n },\n motion: { speed: 1.5, intensity: 0.5, particles: false },\n sound: {\n volume: 0.4,\n events: {\n click: \"tab\",\n countTick: false,\n reward: { url: \"https://…/win.mp3\" },\n },\n },\n});\n```\n\n- Every field is optional; what you leave out keeps its default. The host passes the file to\n `mountHost({ ui })`, and every module on the kit follows it at once.\n- ⚠ **Never restyle a catalog module by editing it.** An edited module is marked \"customized\" and\n stops getting catalog updates. Colours, radii, fonts, animation speed and sounds are all here.\n- Theme tokens: `bgTop`, `bgBottom`, `panel`, `panelDeep`, `panelEdge`, `text`, `textDim`, `gold`,\n `goldDeep`, `green`, `greenDeep`, `blue`, `blueDeep`, `red`, `shadow`, `backdrop`, `radius`,\n `buttonRadius`, `font`, `rarity` (rarity id → frame colour).\n- Motion: `enabled`, `speed` (2 = twice as fast), `intensity` (0 = no bounce), `rewardFlight`,\n `particles`, `stagger`, `respectReducedMotion`.\n- Sound events: `click`, `tab`, `open`, `close`, `reward`, `coin`, `purchase`, `claim`, `error`,\n `notify`, `lootbox`, `levelUp`, `countTick`. Each: another event's name, `{ url }`, `{ synth: {\nnotes: [{ freq, dur, wave }] } }` or `false`.\n- The PLAYER's own sound / volume / \"fewer animations\" live on the device (profile-ui draws them):\n `const [prefs, setPrefs] = usePlayerPrefs()` → `{ muted, volume, reducedMotion }`.\n\n## Features: screens that find each other\n\nA screen module registers a **feature**; whoever draws the menu shows it — the showcase lobby as\ntabs (phone: 4 + \"More\", tablet: more, desktop: a side rail), `game-hud` in its ☰ menu (shared-UI\nrole `\"menu\"`), or the host's floating ☰ when nobody takes the role.\n\n```ts\n// module.ts\nsetup(ctx) {\n ctx.features.register({\n id: \"forge\", label: \"Forge\", icon: \"hammer\", group: \"economy\", order: 60,\n primary: false, // true = a main tab of the lobby\n Screen: ForgeScreen, // (props: FeatureScreenProps) => ReactNode; props.args, props.close()\n });\n // Badge (\"something to claim\"): ctx.features.setBadge(\"forge\", 2)\n}\n```\n\n- Open another module's screen without importing it: `ctx.features.open(\"store\")`,\n `ctx.features.open(\"chat\", { userID })`. Check first with `ctx.features.get(\"store\")` — the\n module may not be installed.\n- Groups: `economy`, `events`, `social`, `profile`. Icons are kit glyph names (`shop`, `hero`,\n `bag`, `trophy`, `gift`, `hammer`, `market`, `offer`, `flame`, `friends`, `chat`, `link`,\n `swords`, `user`, `bulb`, …) or an image URL.\n- Something that must be on screen all the time (a daily auto-popup, a badge watcher, an overlay) is\n a panel: `ctx.registerPanel({ id, slot: \"modal\", activeOnly: false, component })`.\n\n## Building a screen on the kit\n\nKeep the screen a short list; put every detail in a **popup**.\n\n```tsx\nimport {\n Button,\n Popup,\n ResourceList,\n Tabs,\n useCelebrate,\n useToast,\n useUiKit,\n errorText,\n grantedBy,\n panel,\n outlined,\n v,\n} from \"@idosgames/react/ui\";\n\nfunction ForgeScreen(): ReactNode {\n const [open, setOpen] = useState<string | null>(null);\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n {/* rows… onClick={() => setOpen(id)} */}\n {open ? (\n <Popup title=\"Forge\" onClose={() => setOpen(null)} width={420}>\n …\n </Popup>\n ) : null}\n </div>\n );\n}\n```\n\n- `Popup` is a bottom sheet on a phone and a centred window elsewhere (`variant=\"center\"` forces\n centred); it animates in and out and plays open/close sounds. Close from inside with\n `usePopupClose()`. `ConfirmPopup` for yes/no. A popup that opens BY ITSELF (not on a tap) passes\n `queued` — auto-popups then show one at a time instead of stacking.\n- `useCelebrate()(lines)` shows \"You got\" and flies the icons to any element marked\n `data-resource-counter=\"{currencyID}\"` (the lobby and game-hud balances are). Build the lines\n from a response with `grantedBy(data)`; a reward with premium (VIP) bundles needs them added.\n- `useUiKit().play(\"claim\")`, `useUiKit().fx.burst(element)` — a sound and a particle burst.\n- `useToast()(text, \"error\")` for refusals; show `errorText(res.error)`, never re-check server rules.\n- Lines of resources: `grantLines(grant)`, `costLines(cost)`, `bundleLines(bundle)`,\n `canAfford(lines, balances)`, `<ResourceList lines={…} />`. Names and icons: `useCatalog()`.\n- Words: `makeT(EN, RU)` for your strings, `plural(n, \"лот|лота|лотов\")` for counts.\n- ⚠ A root `display: grid` with a wide child (a row of tabs) grows past a phone screen — give it\n `gridTemplateColumns: \"minmax(0, 1fr)\"`.\n\n## Tutorial hints\n\n`tutorial-ui` highlights any element marked `data-tutorial-anchor=\"…\"` — the lobby's tabs are\n`tab:{featureID}`, its profile button `lobby:profile`. Mark the controls of your own screen the same\nway; the publisher points tutorial steps at them in LiveOps (Tutorial → step → AnchorID).\n",
|
|
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 / VIP / heroes / inventory / chests / boosts / leaderboard / daily rewards / quests / collections / craft / marketplace / offers / events / team event / season / friends / chat / invite / arena / multiplayer rooms / profile / tutorials / workshop / AI studio 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 / VIP / heroes / inventory / chests / boosts / leaderboard / daily rewards / quests /\n collections / craft / marketplace / offers / events / team event / season / friends / chat / invite /\n arena / multiplayer rooms / profile / tutorials / workshop / AI studio system instead of 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). The base installs six of them into every new project\n (`inBase`: profile, inventory, invite, friends, quests, mailbox); the rest are installed only when the app\n needs them (a game's `uses`, or a task that needs what they `provide`). ONE system per player-facing module of the engine\n (`API/Client/v2/<Module>`) — the owner's rule since 29.09.2026. The module id is the feature id:\n\n| Module | Engine module | Screen |\n| ------------- | -------------- | ------------------------------------------------------------- |\n| `store` | Store+Purchase | shop: stores as tabs, offers, purchase popup |\n| `premium` | Premium | VIP passes: benefits, timer, free trial, buy / extend |\n| `character` | Character | heroes: gear slots, \"Equip best\", stats, ranks, skins |\n| `inventory` | Item | items by rarity, item card (always shown, even with no items) |\n| `lootboxes` | Lootbox | chests: every way to open, ×1 / ×10, opening animation |\n| `boosts` | TimedBoost | running boosts + global happy hours, activate a boost |\n| `leaderboard` | Leaderboard | tables, my place, rank rewards, last cycle's prize |\n| `rewards` | Reward | daily login calendar, greeting popup |\n| `quests` | Quest | quests by cycle, details, claim all, points track |\n| `collections` | Collection | album of sets, packs, chests for points, card trades |\n| `craft` | Craft | recipes, contract popup |\n| `marketplace` | Marketplace | lots, auctions, selling, trades |\n| `deals` | DealOffer | special offers, auto-popup of a new deal |\n| `events` | TimedEvent | limited-time events, milestones, VIP prize |\n| `coop-events` | CoopEvent | team event: join a group, spin to build, team prize |\n| `season` | Season | season tiers, VIP prize |\n| `friends` | Social | friends, requests, suggestions, news |\n| `chat` | Chat | channels, private chats |\n| `invite` | Referral | own code and link, friend's code, rewards (always shown) |\n| `pvp` | Match | duels, strategy, fight replay |\n| `multiplayer` | Multiplayer | rooms: list, create / join by code, ready, chat, host \"Play\" |\n| `profile` | User | name, sound, language, consent, delete account |\n| `tutorials` | Tutorial | hint overlay + replay list |\n| `workshop` | Workshop | players' and the publisher's content: catalog, publish |\n| `ai` | AI | AI studio: the title's AI features for players, history |\n\nNot a system on purpose: Currency, Authentication, Blockchain (the base — balances, sign-in,\nwallet); GameLoop (the `board-game` game); CommunityMarketing (idosgames.com only); Mailbox and\nAdvertising (no client handler / no web ad network); Analytics, Experiment, Push, Localization,\nCloudCode, DataCollections, TitleCustomData, UserCustomData (services the systems and games call).\n\nHand-offs between systems go through `ctx.features.open(id, args)`: inventory → `character`\n(`{ itemInstanceID }`) and → `lootboxes` (`{ itemID }` of a chest or key); events / season →\n`premium` (`{ premiumID }`). Multiplayer: the `multiplayer` system owns the rooms; a game registers\nin `ctx.multiplayer` and takes the room in `start(room)` (skill **multiplayer-realtime**); the lobby\nshows \"With friends\" next to such a game's \"Play\".\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
5
|
"references": []
|
|
6
6
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mailbox-system",
|
|
3
|
+
"description": "Build a mailbox / inbox in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.mailbox (MailboxService): letters with rewards from the publisher (personal letters and scheduled broadcasts), free daily gifts between friends, transfers of a player's own resources to a friend (declined or expired ones come back), and rewards of other systems the player has not claimed yet (leaderboard cycle, team event prize, invite rewards, missed season tiers). Claim one letter or \"Claim all\" in one server operation, mark read, delete, the badge counters. Use this whenever the user wants a mailbox, inbox, mail, letters, compensation / gift letters, \"send a gift to a friend\", \"claim all\", a mail badge, or touches client.mailbox, MailboxService, MailboxDefinitions, MailboxLetter, MailboxPendingReward — even if they don't name the module. The ready screen is the `mailbox` module (modules/mailbox).",
|
|
4
|
+
"content": "---\nname: mailbox-system\ndescription: >-\n Build a mailbox / inbox in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.mailbox (MailboxService): letters with rewards\n from the publisher (personal letters and scheduled broadcasts), free daily\n gifts between friends, transfers of a player's own resources to a friend\n (declined or expired ones come back), and rewards of other systems the\n player has not claimed yet (leaderboard cycle, team event prize, invite\n rewards, missed season tiers). Claim one letter or \"Claim all\" in one\n server operation, mark read, delete, the badge counters.\n Use this whenever the user wants a mailbox, inbox, mail, letters,\n compensation / gift letters, \"send a gift to a friend\", \"claim all\", a\n mail badge, or touches client.mailbox, MailboxService, MailboxDefinitions,\n MailboxLetter, MailboxPendingReward — even if they don't name the module.\n The ready screen is the `mailbox` module (modules/mailbox).\n---\n\n# Mailbox system (iDosGames TS SDK)\n\nThe mailbox holds **value the player claims**. It is not a notification feed\n(that is Push) and not the social activity inbox (`client.social.getInbox`).\nEverything is **server-authoritative**: expiry, limits, friendship, what may be\ntransferred and every grant are the engine's. The client reads pages, calls an\naction, and renders the result; the SDK applies granted/charged resources to\nthe local cache itself.\n\nA ready screen exists: the **`mailbox` module** (tabs by category, letter popup,\nClaim / Claim all, \"Send gifts\", badge). Compose it instead of writing a new one\nunless the game needs its own look. Open it on a friend from anywhere:\n\n```ts\nctx.features.open(\"mailbox\", { friendUserID, mode: \"gift\" }); // or mode: \"transfer\"\n```\n\n## Key entities\n\n1. **`MailboxDefinitions`** — the title's `Mailbox` config section:\n `Enabled` (off by default — no mailbox, no tab), capacity and letter\n lifetimes, `Templates` (publisher texts per language), `Gifts.Social`\n (free gifts: `Grant`), `Gifts.Transfer` (`Enabled`, `AllowedResources` with\n `MaxAmountPerGift`), `Integrations` (which systems may send missed rewards).\n The client copy has NO daily limits, friendship threshold or sender gates —\n those are anti-fraud limits kept on the server; ask `getGiftStatus()`.\n2. **`MailboxLetter`** — one stored letter: `MessageID`, `Source`\n (Admin, Broadcast, SocialGift, Transfer, TransferReturn, Module, CloudCode),\n `Category` (System, Reward, Gift, Social), `Subject`/`Body` already in the\n requested language, `TemplateID` + `TemplateParams`, `Icon`, `Banner`,\n `Link` (`FeatureID` → `ctx.features.open`, or `Url`), `SenderName`,\n `SenderNote`, `Rewards` (a `ResourceGrant`), `HasRewards`, `IsTransfer`,\n `SentAt`, `ExpiresAt`, `ReadAt`, `ClaimedAt`.\n3. **`MailboxPendingReward`** — a LIVE reward of another system\n (`MessageID = \"live:{Module}:{Key}\"`). The mailbox does not store it: the\n module itself grants it, so claiming it here or on the module's screen is\n the same claim — never both.\n\n⚠ **`Subject`/`Body` can be null.** Letters of the platform's own templates\n(`system.gift`, `system.transfer`, `system.transfer_return`,\n`system.leaderboard_reward`, `system.coop_grand_prize`,\n`system.coop_object_reward`, `system.season_unclaimed`,\n`system.referral_reward`) come without text unless the publisher rewrote the\ntemplate. Render them from your own strings by `TemplateID`, filling\n`{param}` placeholders from `TemplateParams` (`sender`, `rank`, `leaderboard`,\n`event`, `season`, `tier`, `friends`).\n\n## Methods\n\nAll return `Promise<OperationResult<T>>` — branch on `ok`. Codes come back in\n`error` as plain strings (`MailboxErrors`: `ALREADY_CLAIMED`, `EXPIRED`,\n`NOT_FRIENDS`, `ALREADY_SENT`, `SEND_LIMIT`, `RECIPIENT_LIMIT`, `NOT_ENOUGH_RESOURCES`,\n`AMOUNT_TOO_SMALL`, `FRIENDSHIP_TOO_NEW`,\n`AMOUNT_TOO_LARGE`, `RESOURCE_NOT_GIFTABLE`, …) — map them to words.\n\n| Method | What it does |\n| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `getMessages({ cursor?, pageSize?, category?, language? })` | One page, newest first. The FIRST page (no cursor) also delivers broadcasts meant for this player, returns their own expired transfers, and carries `PendingRewards`, `UnreadCount`, `UnclaimedCount`. Language defaults to the client locale. |\n| `getUnreadCount()` | Badge counters only; writes nothing — safe for a menu badge. |\n| `readMessages(ids \\| \"all\")` | Mark read. |\n| `claimReward(messageID)` | One letter or one `live:` reward. |\n| `claimRewards(ids)` | Several in one atomic operation; failures come back in `Failed`. |\n| `claimAll()` | Everything claimable, letters and live rewards. |\n| `deleteMessages(ids \\| \"allRead\", { force? })` | `\"allRead\"` keeps letters with unclaimed rewards. Deleting a pending TRANSFER declines it — the resources go back to the friend. |\n| `sendGift(giftID, recipientUserIDs)` | Gift, once per period per friend (a day unless the title set `IntervalSeconds` from `AnchorUtc`). A gift with `Cost` charges the sender per friend (applied locally from `Resources`). `Sent` / `Failed` per friend. |\n| `sendTransfer(friendID, entries, { requestID, note? })` | Own resources, charged NOW, wait in the friend's letter. |\n| `getGiftStatus()` | Remaining limits of the current period (`RemainingInPeriod`, `ResetsAt`) and who already got each gift in it (`SentTo`). |\n\nClaims return ONE merged `Resources` for the whole request and the service\napplies it once — never add up the per-letter `Claimed[].Rewards` yourself.\n\n⚠ **`requestID` of a transfer is its idempotency key.** Generate one per\nattempt and keep it for retries of the SAME transfer (a timeout then replays\ninstead of charging twice — `IdempotentReplay: true`). Make a new one only after\na success.\n\n## Badge\n\nEvery mailbox response carries fresh counters and the service emits\n`mailbox:countersChanged` (`{ UnreadCount, UnclaimedCount }`). Read\n`getUnreadCount()` once on start (and when the player returns to the tab), then\njust listen — no polling. Counters are capped at `MAILBOX_COUNTER_CAP` (100):\nshow \"99+\". Unread and unclaimed overlap (a new letter with a gift is both) —\nshow the bigger one, not the sum.\n\n```ts\nconst off = client.on(\"mailbox:countersChanged\", (c) =>\n setBadge(Math.max(c.UnreadCount, c.UnclaimedCount)),\n);\nvoid client.mailbox.getUnreadCount();\n```\n\nOther events: `mailbox:messagesLoaded`, `mailbox:rewardsClaimed`,\n`mailbox:giftSent`, `mailbox:transferSent`.\n\n## Recipes\n\n### Inbox with \"Claim all\"\n\n```ts\nconst page = await client.mailbox.getMessages();\nif (!page.ok) return showError(page.error);\nrender(page.data.Messages, page.data.PendingRewards ?? []);\n\nconst all = await client.mailbox.claimAll();\nif (all.ok) celebrate(all.data.Resources); // balances already updated\n```\n\nPaging: pass `NextCursor` back as `cursor`; stop when it is null. Letters of a\nlater page never repeat earlier ones, but de-duplicate by `MessageID` anyway if\nthe list was refreshed in between.\n\n### Gift every friend who has not got one today\n\n```ts\nconst status = await client.mailbox.getGiftStatus();\nconst sent = new Set(\n status.ok ? (status.data.Social[giftID]?.SentTo ?? []) : [],\n);\nconst friends = await client.social.getFriendsList();\nconst open = (friends.ok ? (friends.data.Friends ?? []) : [])\n .map((f) => f.UserID)\n .filter((id) => !sent.has(id));\nawait client.mailbox.sendGift(giftID, open);\n```\n\n### Transfer coins to a friend\n\n```ts\nconst requestID = crypto.randomUUID(); // keep it for retries of THIS transfer\nconst r = await client.mailbox.sendTransfer(\n friendID,\n [{ Type: \"VirtualCurrency\", CurrencyID: \"CO\", Amount: 100 }],\n { requestID, note: \"good luck\" },\n);\n```\n\nOnly resources of `Gifts.Transfer.AllowedResources` can go — `MaxAmountPerGift` is a CAP (any\namount from 1 up to it), entries go separately or together up to `MaxEntriesPerGift`. `FeePercent`\nis withheld from what the friend gets (show `RecipientReceives`), `FeeCost` is charged on top; both\nburn only when the friend claims — a declined or expired transfer returns everything charged; unstackable item instances are never transferable. Friends\nmust be friends long enough (`FRIENDSHIP_TOO_NEW`) — the threshold is not in\nthe client config.\n\n## Gotchas\n\n- **Mailbox off = no calls.** With `Enabled !== true` every action fails with\n `MAILBOX_DISABLED`; hide the tab instead.\n- **Crypto currency never travels by mail** — neither in letters nor\n transfers (`CRYPTO_FORBIDDEN`).\n- **A letter expires** (`ExpiresAt`): its reward can no longer be claimed; a\n transfer that expires goes back to the sender automatically.\n- **Sending letters is not a client action.** Letters come from the publisher\n dashboard (personal letters, broadcasts by segment and schedule), from\n CloudCode (`server.SendMail`), or from the engine's own systems. A client\n can only gift and transfer between friends.\n",
|
|
5
|
+
"references": []
|
|
6
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
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, 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",
|
|
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. In an app with the base (lobby) a game does NOT open rooms: it registers in ctx.multiplayer (registerGame) and takes the room in start(room) — read the first section.",
|
|
4
|
+
"content": "---\nname: multiplayer-realtime\ndescription: >-\n Build realtime peer-to-peer multiplayer in a game on the iDosGames\n TypeScript SDK (@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. In an app with the base (lobby)\n a game does NOT open rooms: it registers in ctx.multiplayer (registerGame) and takes the room in\n start(room) — read the first section.\n---\n\n# Multiplayer realtime signaling (iDosGames TS SDK)\n\nMultiplayer is a **realtime WebRTC signaling relay**, not a gameplay or\neconomy module. It gives you room lifecycle (create/join/leave/list/close) and\na message relay for two things peer-to-peer games need: SDP offer/answer + ICE\ncandidate exchange (so browsers/clients can establish a direct WebRTC\nconnection), and optional server-relayed chat/presence. The actual game state\nsync between players happens over the WebRTC connection you establish\nyourself — this module only gets players into the same room and helps them\nfind each other.\n\n**No currency/item coupling, no per-player cache mirror.** Unlike every other\nmodule in this SDK, no Multiplayer action ever returns a `ResourceOperation`\nor writes to `client.data.user.state` — there is nothing to mirror into a\nlocal player-state cache. Room state itself is ephemeral (Redis, TTL-based, no\nMongo document), so the SDK surface here is pure transport + event-emission:\ncall a method, get a response, emit an event, done. There is no\n`user:multiplayerUpdated` event and no `client.data.user.state.Multiplayer` —\ndon't look for either. The one piece of _config_ this module does expose —\n`MultiplayerDefinitions` (feature switch, topology/chat defaults, room-size\ncap, TTL, payload limits, ICE servers) — rides in on the title's public\nconfig bundle rather than a dedicated fetch call; see\n[Reading limits from config](#reading-limits-from-config).\n\nThis skill is for **using** the production `MultiplayerService`, not for\nporting or extending it. **Don't hand-roll WebRTC** on top of it: the SDK ships\n`RealtimeSession` (`@idosgames/core/realtime`, see\n[Recommended: RealtimeSession](#recommended-realtimesession)) which owns the\n`RTCPeerConnection`s, the poll loop and the signaling etiquette the backend\nexpects. The raw methods below are for lobby UI and for the rare custom\ntransport. Server rejections (room full, wrong join\ncode, not the owner, payload too large) are backend rules — surface them,\ndon't try to pre-validate every one of them client-side.\n\n## A game in an app with the base: `ctx.multiplayer` — do NOT open rooms yourself\n\nIn a project built on the base (lobby) the ROOMS belong to the multiplayer system (the `multiplayer`\nmodule): the list of rooms, \"Create a room\", joining by invite, members, \"Ready\", chat — and the\n`RealtimeSession`, opened already in the lobby. A game never calls `createRoom`/`joinRoom`/\n`RealtimeSession.create|join`; it declares how it plays online and TAKES the room:\n\n```ts\n// module.ts, in setup()\nctx.multiplayer.registerGame({\n mode: \"my-game\", // the game's route id — the room's game\n topology: \"Host\", // \"Host\" (star) | \"Mesh\"\n chatMode: \"P2P\", // the game has its own chat; \"ServerRelay\" = the lobby's chat\n maxPlayers: 8,\n joinInProgress: true, // a player may join a running game (your late-joiner path)\n settings: [\n // the host picks them in \"Create a room\"\n {\n id: \"mode\",\n label: \"Mode\",\n kind: \"choice\",\n default: \"coop\",\n options: [\n { value: \"coop\", label: \"Co-op\" },\n { value: \"pvp\", label: \"PvP\" },\n ],\n },\n { id: \"cheats\", label: \"Cheats for all\", kind: \"toggle\", default: false },\n ],\n // Called on EVERY member's device after the app switched to the game's mode (the host first).\n start: async (room) => {\n const game = await whenGameMounted(); // the scene may mount on this very switch\n game.playOnline(room.session, {\n host: room.isHost && !room.lateJoin, // opens its world for the others\n settings: room.settings, // { mode: \"coop\", cheats: false }\n leave: room.leave, // leave through the lobby, never session.leave()\n });\n },\n});\n```\n\n- The lobby then draws \"With friends\" next to the game's \"Play\" (its rooms), and the rooms screen\n offers the game in \"Create a room\". A game's own \"Play with friends\" button:\n `ctx.features.open(\"multiplayer\", { mode: \"my-game\", create: true })` — from inside the game it\n switches to the lobby by itself.\n- Show that button only while `ctx.features.get(\"multiplayer\")?.available` — without the system the\n game is single-player, nothing else changes.\n- Leave with `room.leave()`: the lobby owns the room and forgets it together with the session.\n- Messages on the session are yours from `start` on; the lobby's own start message goes out before\n any of yours on the ordered channel and never reaches the game.\n- Reference implementation: `modules/voxelcraft` (`module.ts` → `registerGame`, `net/NetGame.ts` →\n `NetGame.fromRoom`, `main.ts` → `Game.acceptRoom`).\n\nEverything below — `client.multiplayer` and `RealtimeSession` directly — is for an app WITHOUT the\nbase, or for writing the rooms system itself.\n\n## Mental model\n\n1. One player **creates a room** (picks topology/chat mode/visibility/max\n players, optionally a join code); others **join** by room id (+ join code\n if private). **A player can only be in one room at a time** — creating or\n joining while already in a live room fails; leave first.\n2. Each member **polls** the room on a cursor (`LastSeq`) to receive a stream\n of envelopes: other members' signals, chat, presence changes\n (join/leave/host-migration), and room-closed notices. Polling is also the\n heartbeat — it's what keeps your presence and the room's Redis TTL alive.\n3. To establish a direct connection, members exchange SDP offer/answer and ICE\n candidates by calling **`sendSignal`** — the server just relays the\n `payload` string to a specific `targetUserID`; it never inspects or\n validates WebRTC semantics.\n4. Optional **`sendChat`** (only meaningful when the room's `ChatMode` is\n `ServerRelay` — under `P2P` chat should go over your own DataChannel\n instead) and **`setMemberState`** (ready flags + a free-form string map)\n round out lobby UX.\n5. Someone **leaves**, or the owner/host **closes** the room; on host\n departure the server may migrate to a `NewHostUserID` if members remain.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst mp = client.multiplayer; // the MultiplayerService\n```\n\nEvery method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args — missing room id/name/target/kind), `\"unauthorized\"`,\n`\"throttled\"` (fired the same endpoint again inside the transport's throttle\nwindow), `\"connection\"` (transient, offer Retry), `\"validation\"`\n(response/schema drift), or `\"server\"` (the backend rejected it — `error`\ncarries a human-readable reason; see the verbatim strings below).\n\n| Method | Purpose | `data` on success |\n| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------ |\n| `createRoom(roomName, options?)` | Create a room; caller becomes owner/host. | `RoomSnapshotResponse` |\n| `joinRoom(roomID, joinCode?)` | Join an existing room. | `RoomSnapshotResponse` |\n| `leaveRoom(roomID, { successorUserID?, keepalive? }?)` | Leave a room (may trigger host migration or close it if empty). A leaving host may name the successor; `keepalive` lets the request outlive the page (`pagehide`). | `LeaveRoomResponse` |\n| `listRooms(page?, pageSize?)` | Page through public rooms (default `page=0, pageSize=20`, server caps `pageSize` at 50). | `RoomsPageResponse` |\n| `getRoom(roomID)` | Fetch a fresh snapshot of one room. | `RoomSnapshotResponse` |\n| `closeRoom(roomID)` | Owner/host-only: force-close a room. | `LeaveRoomResponse` |\n| `poll(roomID, lastSeq)` | Long-poll cursor: get every envelope with `Seq > lastSeq`. | `PollResponse` |\n| `sendSignal(roomID, targetUserID, signalKind, payload)` | Relay an SDP offer/answer/ICE candidate to one member. | `PublishResponse` |\n| `sendChat(roomID, chatText)` | Server-relayed chat message (only when `ChatMode: ServerRelay`). | `PublishResponse` |\n| `setMemberState(roomID, ready?, memberState?)` | Publish this member's ready flag / free-form lobby state. | `PublishResponse` |\n| `claimHost(roomID)` | Take the host role from a host that stopped polling (silent longer than `MemberTimeoutMs`). The silent host is evicted; `PlayerLeft` (Kind `\"timeout\"`) + `HostChanged` go to everyone. Exactly one claimant wins. | `RoomSnapshotResponse` |\n| `kickMember(roomID, targetUserID, ban?)` | Owner/host-only: remove a member (`PlayerLeft`, Kind `\"kicked\"`); `ban` also puts them on the room's ban list (survives host changes, dies with the room). Their TURN credentials are revoked. | `KickMemberResponse` (`Removed`, `Banned`) |\n| `unbanMember(roomID, targetUserID)` | Owner/host-only: take a player off the ban list. | `UnbanMemberResponse` (`WasBanned`) |\n\n`createRoom`'s `options`: `{ topology?: MultiplayerTopology (\"Mesh\"|\"Host\"),\nchatMode?: MultiplayerChatMode (\"ServerRelay\"|\"P2P\"), maxPlayers?: number,\nvisibility?: RoomVisibility (\"Public\"|\"Private\"), joinCode?: string }`. Any\nfield you omit falls back to the title's configured defaults\n(`MultiplayerDefinitions.DefaultTopology`/`DefaultChatMode`), and an override\nis only honored if the title allows it\n(`AllowCreatorTopologyOverride`/`AllowCreatorChatOverride`) — otherwise the\ncall fails server-side with `\"Topology override is not allowed for this\ntitle.\"` / `\"Chat mode override is not allowed for this title.\"`.\n`maxPlayers` is clamped down to the title's `MaxPlayersPerRoom` hard cap; you\ncan only ask for _fewer_, never more.\n\n`closeRoom` succeeds for either the room's `OwnerUserID` **or** its current\n`HostUserID` (these can diverge after a host migration) — anyone else gets\n`\"Only the room owner or host can close the room.\"`.\n\nEvery method still emits its event on success even though nothing is cached —\nevents are your only hook here, there's no cache to read afterward.\n\n### Server error strings (verbatim)\n\nThese are exact `error` text from the backend (`RoomService.cs` /\n`SignalingService.cs`) — match on substrings if you need to branch, but the\nsafe default is just to display `result.error` as-is:\n\n- `\"Multiplayer is disabled for this title.\"` — title-wide kill switch is off.\n- `\"You are already in room {roomId}. Leave it first.\"` — one-active-room rule,\n on both `createRoom` and `joinRoom`.\n- `\"RoomID is required.\"` / `\"TargetUserID is required.\"` / `\"SignalKind is\nrequired.\"` — same validation the SDK does client-side; you'll only see\n these from a raw/bypassed call.\n- `\"Invalid or missing join code.\"` — private room join code mismatch.\n- `\"Room not found.\"` — unknown room id, or a **private** room you aren't a\n member of (privacy is enforced by returning the same not-found error, not a\n distinct \"forbidden\").\n- `\"Room is closed.\"` / `\"Room is full.\"` — join-time rejections.\n- `\"Only the room owner or host can close the room.\"`\n- `\"You were removed from this room.\"` — `joinRoom` by a banned player.\n- `\"The host is still active.\"` — `claimHost` while the host still polls\n (retry later; only a host silent for `MemberTimeoutMs` can be replaced).\n- `\"Only the room owner or host can remove players.\"` / `\"Cannot kick\nyourself.\"` / `\"Cannot remove the host.\"` / `\"Cannot remove the room owner.\"`\n — `kickMember` / `unbanMember`.\n- `\"Room no longer exists.\"` / `\"You are not a member of this room.\"` — from\n `poll`, `sendSignal`, `sendChat`, `setMemberState` once you've dropped out of\n the member hash (TTL expiry, or someone else removed you).\n- `\"Target user is not in this room.\"` — `sendSignal` to a non-member.\n- `\"Cannot signal yourself.\"`\n- `` `Signal payload exceeds {N} bytes.` `` — `MaxSignalPayloadBytes` (default 16384) exceeded.\n- `` `Chat text exceeds {N} characters.` `` — `MaxChatTextLength` (default 500)\n exceeded.\n- `\"Chat is peer-to-peer in this room; send it over the DataChannel.\"` —\n called `sendChat` while `ChatMode` is `P2P`.\n- `\"Corrupted member record.\"` — internal deserialization failure on\n `setMemberState`; treat as transient/report.\n\n## Reading limits from config\n\nThere's no `getMultiplayerDefinitions()` call — `MultiplayerDefinitions`\ntravels as part of the title's public config bundle, not a per-module\ndefinitions fetch. It's populated by `client.title.getTitlePublicConfiguration()`\n(or already present after login, since the bootstrap `ClientState` includes\nthe title config):\n\n```ts\nimport type { MultiplayerDefinitions } from \"@idosgames/core\";\n\nawait client.title.getTitlePublicConfiguration(); // if not already loaded\nconst cfg = client.data.config.titlePublicConfiguration?.Multiplayer as\n MultiplayerDefinitions | undefined;\n\ncfg?.MaxPlayersPerRoom; // hard cap enforced server-side\ncfg?.MaxChatTextLength;\ncfg?.MaxSignalPayloadBytes;\ncfg?.HeartbeatIntervalMs; // recommended poll cadence, not enforced\ncfg?.MemberTimeoutMs; // client-side presumed-offline threshold\n```\n\nRead it for building config-driven UI (create-room forms that pre-fill/clamp\n`maxPlayers`, a chat box that disables itself when `ChatMode` will be `P2P`,\netc.) — the server enforces the real limits regardless of what you show.\n\n## Events\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn. There is no\ncoarse `user:*Updated`/`user:anyUpdated` companion for any of these — they\ndon't touch the shared cache.\n\n- `multiplayer:roomCreated` → `RoomSnapshotResponse`\n- `multiplayer:roomJoined` → `RoomSnapshotResponse`\n- `multiplayer:roomLeft` → `LeaveRoomResponse`\n- `multiplayer:roomsListed` → `RoomsPageResponse`\n- `multiplayer:roomLoaded` → `RoomSnapshotResponse`\n- `multiplayer:roomClosed` → `LeaveRoomResponse`\n- `multiplayer:polled` → `PollResponse`\n- `multiplayer:signalSent` → `PublishResponse`\n- `multiplayer:chatSent` → `PublishResponse`\n- `multiplayer:memberStateSet` → `PublishResponse`\n- `multiplayer:hostClaimed` → `RoomSnapshotResponse`\n- `multiplayer:memberKicked` → `KickMemberResponse`\n- `multiplayer:memberUnbanned` → `UnbanMemberResponse`\n\n```ts\nconst off = client.on(\"multiplayer:roomClosed\", (r) => {\n if (r.Closed) navigateToLobbyList();\n});\n// later: off();\n```\n\nFor the full shape of `RoomSnapshotResponse`, `RealtimeEnvelope`,\n`RoomMemberView`, and `MultiplayerDefinitions`, see\n[references/data-model.md](references/data-model.md).\n\n## Recommended: RealtimeSession\n\n`@idosgames/core/realtime` is the WebRTC transport on top of this module. Use it\ninstead of writing the offer/answer/ICE dance yourself:\n\n```ts\nimport { RealtimeSession } from \"@idosgames/core/realtime\";\n\nconst res = await RealtimeSession.create(client, \"My world\", {\n topology: \"Host\", // star: guests link to the host only\n visibility: \"Public\",\n maxPlayers: 8,\n});\n// guests: await RealtimeSession.join(client, roomID, joinCode?)\nif (!res.ok) return showError(res.error);\nconst session = res.data;\n\nsession.on(\"peerOpen\", ({ peerId }) => sendWorldSnapshot(peerId));\nsession.on(\"message\", ({ from, channel, data }) => handle(from, channel, data));\nsession.on(\"hostChanged\", ({ hostId }) => {\n if (hostId === session.selfId) becomeHost();\n});\nsession.on(\"removed\", ({ reason }) => backToMenu(reason)); // kicked / banned\nsession.on(\"roomClosed\", () => backToMenu(\"closed\"));\n\nsession.send(peerId, \"rel\", bytes); // ordered + reliable, fragmented into 16 KB frames\nsession.broadcast(\"fast\", poseBytes); // unordered, no retransmits: positions etc.\n```\n\nWhat it does for you:\n\n- **Two DataChannels per link**: `rel` (ordered, reliable; large messages are\n fragmented) and `fast` (unordered, `maxRetransmits: 0`). `send` returns\n `false` when the link is not open, a `fast` message is too big, or `rel` is\n backed up — drop or retry, don't queue forever.\n- **Cheap polling**: 1 s only while something is being set up, then the host\n polls every 2.5 s (to see newcomers) and everyone else every 20 s (just to\n stay a member). Signals go through a single-flight, per-peer batched outbox,\n and descriptions carry their ICE candidates — about one `SendSignal` per\n side per connection.\n- **Liveness**: own keepalive on `fast` (a silent peer is closed after ~6 s),\n RTT via `linkStats(peerId)` (measured every ~5 s even under load),\n reconnect with back-off.\n- **Host handover**: the host calls `setSuccessor(userId)`; `leave()` (and\n page close — a `keepalive` request on `pagehide`) passes it to the server.\n If the host tab dies instead, the successor calls `session.claimHost()`\n once the host has been silent for the title's `MemberTimeoutMs`.\n `setExtraPeers([...])` keeps standby links (e.g. every guest to the\n successor) so the handover does not wait for signaling.\n- **Moderation**: `session.kick(userId, ban?)`, `session.unban(userId)`. After\n a successful kick the host sends the kicked player a \"check your membership\"\n notice over the link; that player polls at once and gets `removed` within\n about a round trip. The notice is only a hint — the SERVER confirms the\n removal, so a forged notice costs one extra poll and nothing else.\n- **TURN**: `IceServers` of the snapshot are used as is. When the engine has\n Cloudflare TURN configured (env `CLOUDFLARE_TURN_KEY_ID` / `CLOUDFLARE_TURN_API_TOKEN` on the\n engine — not title config) members get short-lived TURN credentials in the\n snapshot; otherwise STUN only.\n\nA full game built on it: `modules/voxelcraft/src/net/` (NetGame — dimension\nowners, replicas, host migration with a shadow copy of the world).\n\n## Recipes\n\n### Create or join a room\n\n```ts\nconst created = await client.multiplayer.createRoom(\"Match 42\", {\n topology: \"Mesh\",\n chatMode: \"ServerRelay\",\n maxPlayers: 4,\n visibility: \"Private\",\n joinCode: \"AB12\",\n});\nif (!created.ok) return showError(created.error);\nconst { RoomID, Seq, IceServers, Members } = created.data;\n\n// another player:\nconst joined = await client.multiplayer.joinRoom(RoomID, \"AB12\");\nif (!joined.ok) return showError(joined.error); // e.g. wrong code, room full\n```\n\n`IceServers` on the snapshot is the STUN/TURN config for your `RTCPeerConnection`\n— pass it straight through (if the title has none configured, the backend\nfalls back to public STUN servers, so this array is never empty). TURN\ncredentials, when the engine has Cloudflare TURN configured, are included only\nfor room members and expire after ~12 h — never put a static TURN login into\nthe title's public config.\n`Seq` is your starting poll cursor. Remember the one-active-room rule: a\nsecond `createRoom`/`joinRoom` call while already in a room fails with\n`\"You are already in room {roomId}. Leave it first.\"` — call `leaveRoom` (or\ncheck your own app state) before switching rooms.\n\n### The polling loop (signals, chat, presence)\n\nPoll is a cursor-based long-poll: pass the last `Seq` you've seen, get back\neverything newer, and each response's `MaxSeq` becomes your next cursor. Each\npoll call also renews your presence heartbeat and the room's TTL server-side\n— an idle client that stops polling will eventually look disconnected to\nothers and the room itself can expire.\n\n```ts\nlet lastSeq = created.data.Seq ?? 0;\nlet polling = true;\n\nasync function pollLoop(roomID: string) {\n while (polling) {\n const res = await client.multiplayer.poll(roomID, lastSeq);\n if (!res.ok) {\n if (res.reason === \"connection\") continue; // transient, retry\n break; // unauthorized/server error (\"Room no longer exists.\", etc) — stop and surface it\n }\n for (const env of res.data.Envelopes ?? []) {\n switch (env.Type) {\n case \"Signal\":\n // env.Kind: \"offer\" | \"answer\" | \"candidate\"; env.FromUserID, env.Payload\n handleSignal(env.FromUserID, env.Kind, env.Payload);\n break;\n case \"Chat\":\n appendChatMessage(env.FromUserID, env.Payload);\n break;\n case \"PlayerJoined\":\n case \"PlayerLeft\":\n case \"MemberState\":\n refreshRoomMembers(roomID);\n break;\n case \"HostChanged\":\n break; // env.Payload / a follow-up getRoom() carries new host\n case \"RoomClosed\":\n polling = false;\n break;\n }\n }\n lastSeq = res.data.MaxSeq ?? lastSeq;\n }\n}\n```\n\nThere's no push/websocket transport here — polling cadence is your call. The\ntitle's `MultiplayerDefinitions.HeartbeatIntervalMs` (default 1000ms) is a\n_recommended_ interval only, not enforced — poll too fast and you risk\n`reason: \"throttled\"` at the transport layer; too slow and signaling feels\nlaggy, and other members' `LastSeenMs` will look stale to you. Use the\nrecommended interval as your default; back off on `\"connection\"` errors\nrather than hot-looping.\n\n### Exchange a WebRTC offer/answer\n\n```ts\n// caller side, after creating an RTCPeerConnection and an offer:\nconst offer = await pc.createOffer();\nawait pc.setLocalDescription(offer);\nawait client.multiplayer.sendSignal(\n roomID,\n targetUserID,\n \"offer\",\n JSON.stringify(offer),\n);\n\n// callee side, from the poll loop's \"Signal\"/\"offer\" case:\nconst offer = JSON.parse(env.Payload);\nawait pc.setRemoteDescription(offer);\nconst answer = await pc.createAnswer();\nawait pc.setLocalDescription(answer);\nawait client.multiplayer.sendSignal(\n roomID,\n env.FromUserID,\n \"answer\",\n JSON.stringify(answer),\n);\n\n// both sides, as ICE candidates trickle in:\npc.onicecandidate = (e) => {\n if (e.candidate) {\n client.multiplayer.sendSignal(\n roomID,\n targetUserID,\n \"candidate\",\n JSON.stringify(e.candidate),\n );\n }\n};\n```\n\n`signalKind` is a free-form string (`\"offer\"`/`\"answer\"`/`\"candidate\"` by\nconvention) and `payload` is an opaque string — the server relays both without\ninspecting them, so encoding is entirely up to you (JSON-stringify is the\nobvious choice). Keep the serialized payload under the title's\n`MaxSignalPayloadBytes` (default 16384) — an oversized payload is rejected\nwith `` `Signal payload exceeds {N} bytes.` `` before it's ever relayed, and\nyou can't signal yourself (`\"Cannot signal yourself.\"`) or a user who isn't\ncurrently a room member (`\"Target user is not in this room.\"`).\n\n### Lobby ready-check and leaving\n\n```ts\nawait client.multiplayer.setMemberState(roomID, true, { loadout: \"red\" });\n// other members see this via a \"MemberState\" envelope on their next poll,\n// then re-fetch with getRoom(roomID) to read updated Members[].Ready/State.\n\nconst left = await client.multiplayer.leaveRoom(roomID);\nif (left.ok && left.data.Closed) {\n // room was empty/owner left with no migration target — it's gone\n} else if (left.ok && left.data.NewHostUserID) {\n // host migrated; that member is now authoritative for Host topology\n}\n```\n\n## Gotchas\n\n- **Nothing here touches `client.data.user.state`.** Don't look for a cached\n room state or resource balances changing — read everything from method\n responses and poll envelopes, and keep your own local room/member state in\n your app layer. The one thing that _is_ cached is the title-wide\n `MultiplayerDefinitions` config, and only after you fetch the title config\n (see [Reading limits from config](#reading-limits-from-config)).\n- **One room per player, enforced server-side.** `createRoom` and `joinRoom`\n both reject with `\"You are already in room {roomId}. Leave it first.\"` if\n you're already a live member elsewhere — there's no implicit \"leave old,\n join new.\"\n- **Room state is ephemeral.** No persistent document backs a room — it's\n Redis, TTL-based (`RoomTtlSeconds`, default 3600s, renewed on every\n interaction including `poll`). A room can disappear if nobody interacts\n with it; don't assume a `RoomID` is valid indefinitely.\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",
|
|
@@ -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
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "workshop-system",
|
|
3
3
|
"description": "Let players share what they make — maps, levels, worlds, skins, 3D models, any file — through the Workshop of the iDosGames TypeScript SDK (@idosgames/core client.workshop, WorkshopService): configure content types for a title, publish with files and a thumbnail, set access (free, a price in in-game resources, or \"hold these resources to unlock\" — while held or once), browse the catalog with filters and publisher collections, acquire, download and open content, likes, favorites, following authors, reports, official content. Also covers plugging a game into the ready-made `workshop` module via ctx.content (ContentTypeHandler: listLocal / capture / open). Use this whenever the user wants user-generated content, a level/map sharing screen, selling maps or skins between players, unlocking content for holders of an item, a creator catalog, or touches client.workshop, WorkshopService, publish, acquire, downloadFiles, WorkshopAccessOption, WorkshopDefinitions, ContentTypes, ctx.content or the workshop module — even if they don't name the module.",
|
|
4
|
-
"content": "---\nname: workshop-system\ndescription: >-\n Let players share what they make — maps, levels, worlds, skins, 3D models, any\n file — through the Workshop of the iDosGames TypeScript SDK\n (@idosgames/core client.workshop, WorkshopService): configure content types for\n a title, publish with files and a thumbnail, set access (free, a price in\n in-game resources, or \"hold these resources to unlock\" — while held or once),\n browse the catalog with filters and publisher collections, acquire, download\n and open content, likes, favorites, following authors, reports, official\n content. Also covers plugging a game into the ready-made `workshop` module via\n ctx.content (ContentTypeHandler: listLocal / capture / open). Use this whenever\n the user wants user-generated content, a level/map sharing screen, selling\n maps or skins between players, unlocking content for holders of an item, a\n creator catalog, or touches client.workshop, WorkshopService, publish,\n acquire, downloadFiles, WorkshopAccessOption, WorkshopDefinitions,\n ContentTypes, ctx.content or the workshop module — even if they don't name\n the module.\n---\n\n# Workshop (iDosGames TS SDK)\n\nThe Workshop is a catalog of content made by players (and by the publisher — \"official\"). It is\n**not** the Marketplace: nothing is transferred. Acquiring grants a **license** to a digital copy —\none publication is acquired by many players and the author keeps it. For trading actual items\nbetween players use **marketplace-system**.\n\nEverything game-specific lives in the title config (`Workshop` section, edited on the dashboard\npage LiveOps → Workshop): which content types exist, which files each has, who may publish, which\naccess modes authors may offer, commission, moderation. The server enforces all of it — surface a\nrefusal, don't re-implement the check.\n\n## Two layers\n\n| You want | Use |\n| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |\n| A ready catalog screen in a composed game | install the **`workshop` module** and register your content type with `ctx.content` (below) — no Workshop code of your own |\n| Your own UI, or a game without the module system | call `client.workshop.*` directly |\n\n## Config: content types\n\n`Workshop.ContentTypes` is a dictionary keyed by the type id your game sends:\n\n```jsonc\n\"Workshop\": {\n \"Enabled\": true,\n \"ContentTypes\": {\n \"voxelcraft.world\": {\n \"DisplayName\": \"World\",\n \"Files\": { \"main\": { \"AllowedMimeTypes\": [\"application/json\"], \"MaxBytes\": 10485760 } },\n // \"extra\": { \"AllowedMimeTypes\": [\"model/gltf-binary\"], \"MaxCount\": 4, \"Required\": false }\n \"AllowedAccessModes\": [\"Free\", \"Price\", \"Holding\"], // null = all\n \"PublishFeeOptions\": null, // PriceOptions, Standard part only\n \"MaxPublishedPerPlayer\": 50, \"DailyPublishCap\": 10\n }\n },\n \"Moderation\": { \"AutoHideReportThreshold\": 5, \"RequireApproval\": false }\n}\n```\n\n- `Files: null` = one required `main` JSON file ≤ 10 MB. The server checks size and **file\n signature** against the declared MIME type; `application/octet-stream` passes only if listed.\n- The section is **not** in the public title config the client caches (it holds the moderation\n blocklist). Read what the client may know with `client.workshop.getDefinitions()` — which also\n says which types this player may publish and which collections are live.\n\n## Access options — ANY one opens\n\nA publication carries a list of options; the player needs to satisfy **one**:\n\n```ts\naccess: [\n {\n Mode: \"Holding\",\n Holding: {\n Match: \"Any\",\n Mode: \"WhileHeld\",\n Requirements: [\n { Type: \"Item\", CatalogID: \"keys\", ItemID: \"gold_key\", Amount: 1 },\n ],\n },\n },\n {\n Mode: \"Price\",\n Price: {\n Entries: [{ Type: \"VirtualCurrency\", CurrencyID: \"GOLD\", Amount: 100 }],\n },\n },\n];\n// = free for holders of a gold key, 100 GOLD for everyone else\n```\n\n- `Free` — anyone.\n- `Price` — the buyer pays, the author receives the price minus the title's commission. Official\n content: the whole price goes to the title.\n- `Holding` — nothing is spent. `WhileHeld`: checked on **every download**, spend the key and access\n is gone (no license is written). `UnlockOnce`: checked once, then a permanent license. `Match: All`\n needs every requirement, `Any` one of them. Items may carry `MinLevel`.\n\n## Client API (`client.workshop`)\n\n```ts\nconst defs = await client.workshop.getDefinitions();\nconst page = await client.workshop.browse({\n contentType: \"voxelcraft.world\",\n sort: \"Popular\",\n});\nconst card = await client.workshop.getContent(contentID); // + per-option availability\n\nconst pub = await client.workshop.publish({\n contentType: \"voxelcraft.world\",\n files: [\n {\n role: \"main\",\n contentType: \"application/json\",\n data: JSON.stringify(save),\n },\n ],\n thumbnail: { contentType: \"image/webp\", data: webpBlob },\n title: \"Frost Keep\",\n tags: [\"castle\"],\n visibility: \"Public\", // Public | Unlisted | Friends\n access: [{ Mode: \"Free\" }],\n});\n\nconst got = await client.workshop.acquire(contentID, option); // pass the option you SHOWED\nconst dl = await client.workshop.downloadFiles(contentID); // bytes of every file\n```\n\n- `publish` does declare → upload straight to storage by signed URLs → verify and release. With\n `contentID` it uploads a new revision of your own publication. A file from `client.ai` can be\n published by URL: `{ role, contentType, sourceAssetUrl }` — the server copies it.\n- **`acquire(contentID, option)` sends `ExpectedPrice = option.Price`.** If the author changed the\n price meanwhile the server refuses instead of charging a price the player never saw — re-read the\n card and ask again. Acquiring twice is safe: the second answer says `AlreadyOwned`, nothing charged.\n- `getDownload` / `downloadFiles` return short-lived signed links — fetch right away, don't store them.\n- Also: `updateContent`, `unpublish` (buyers keep access), `getMyContent`, `getMyLicenses`,\n `getMyFavorites`, `getCreatorProfile`, `getCollection`.\n- ⚠ **Likes, favorites, following an author, comments and reports are `client.social`, not\n `client.workshop`** (see the `social-system` skill): `client.social.like(\"WorkshopItem\", id)`,\n `favorite(\"WorkshopItem\", id)`, `follow(\"User\", creatorUserID)`,\n `report(\"WorkshopItem\", id, \"Broken\")`, `addComment(\"WorkshopItem\", id, text)`. The Workshop\n rules still apply to them (gate, visibility, \"can't like your own\", \"you may report what you\n already got\"). Cards keep `Stats.Likes`/`Favorites` and `Liked`/`Favorited`; the author's follower\n count and \"am I following\" come from `getCreatorProfile` or `client.social.getEntityStates(\"User\", …)`.\n- Events: `workshop:definitionsLoaded | published | updated | acquired` (reactions emit\n `social:entityStateChanged`).\n\n## Plugging a game into the `workshop` module\n\n```ts\nsetup(ctx) {\n ctx.content.registerType({\n type: \"level\", // = key of Workshop.ContentTypes\n label: \"Level\", icon: \"🧩\", modeId: \"my-game\",\n listLocal: async () => myLevels.map((l) => ({ id: l.id, name: l.name })),\n capture: async (id) => ({ files: [{ role: \"main\", contentType: \"application/json\",\n data: JSON.stringify(load(id)) }], suggestedTitle: load(id).name }),\n open: async (c) => startLevel(JSON.parse(new TextDecoder().decode(c.files[0].data))),\n });\n}\n```\n\nThe module lists your local items, publishes them, and after `open
|
|
4
|
+
"content": "---\nname: workshop-system\ndescription: >-\n Let players share what they make — maps, levels, worlds, skins, 3D models, any\n file — through the Workshop of the iDosGames TypeScript SDK\n (@idosgames/core client.workshop, WorkshopService): configure content types for\n a title, publish with files and a thumbnail, set access (free, a price in\n in-game resources, or \"hold these resources to unlock\" — while held or once),\n browse the catalog with filters and publisher collections, acquire, download\n and open content, likes, favorites, following authors, reports, official\n content. Also covers plugging a game into the ready-made `workshop` module via\n ctx.content (ContentTypeHandler: listLocal / capture / open). Use this whenever\n the user wants user-generated content, a level/map sharing screen, selling\n maps or skins between players, unlocking content for holders of an item, a\n creator catalog, or touches client.workshop, WorkshopService, publish,\n acquire, downloadFiles, WorkshopAccessOption, WorkshopDefinitions,\n ContentTypes, ctx.content or the workshop module — even if they don't name\n the module.\n---\n\n# Workshop (iDosGames TS SDK)\n\nThe Workshop is a catalog of content made by players (and by the publisher — \"official\"). It is\n**not** the Marketplace: nothing is transferred. Acquiring grants a **license** to a digital copy —\none publication is acquired by many players and the author keeps it. For trading actual items\nbetween players use **marketplace-system**.\n\nEverything game-specific lives in the title config (`Workshop` section, edited on the dashboard\npage LiveOps → Workshop): which content types exist, which files each has, who may publish, which\naccess modes authors may offer, commission, moderation. The server enforces all of it — surface a\nrefusal, don't re-implement the check.\n\n## Two layers\n\n| You want | Use |\n| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |\n| A ready catalog screen in a composed game | install the **`workshop` module** and register your content type with `ctx.content` (below) — no Workshop code of your own |\n| Your own UI, or a game without the module system | call `client.workshop.*` directly |\n\n## Config: content types\n\n`Workshop.ContentTypes` is a dictionary keyed by the type id your game sends:\n\n```jsonc\n\"Workshop\": {\n \"Enabled\": true,\n \"ContentTypes\": {\n \"voxelcraft.world\": {\n \"DisplayName\": \"World\",\n \"Files\": { \"main\": { \"AllowedMimeTypes\": [\"application/json\"], \"MaxBytes\": 10485760 } },\n // \"extra\": { \"AllowedMimeTypes\": [\"model/gltf-binary\"], \"MaxCount\": 4, \"Required\": false }\n \"AllowedAccessModes\": [\"Free\", \"Price\", \"Holding\"], // null = all\n \"PublishFeeOptions\": null, // PriceOptions, Standard part only\n \"MaxPublishedPerPlayer\": 50, \"DailyPublishCap\": 10\n }\n },\n \"Moderation\": { \"AutoHideReportThreshold\": 5, \"RequireApproval\": false }\n}\n```\n\n- `Files: null` = one required `main` JSON file ≤ 10 MB. The server checks size and **file\n signature** against the declared MIME type; `application/octet-stream` passes only if listed.\n- The section is **not** in the public title config the client caches (it holds the moderation\n blocklist). Read what the client may know with `client.workshop.getDefinitions()` — which also\n says which types this player may publish and which collections are live.\n\n## Access options — ANY one opens\n\nA publication carries a list of options; the player needs to satisfy **one**:\n\n```ts\naccess: [\n {\n Mode: \"Holding\",\n Holding: {\n Match: \"Any\",\n Mode: \"WhileHeld\",\n Requirements: [\n { Type: \"Item\", CatalogID: \"keys\", ItemID: \"gold_key\", Amount: 1 },\n ],\n },\n },\n {\n Mode: \"Price\",\n Price: {\n Entries: [{ Type: \"VirtualCurrency\", CurrencyID: \"GOLD\", Amount: 100 }],\n },\n },\n];\n// = free for holders of a gold key, 100 GOLD for everyone else\n```\n\n- `Free` — anyone.\n- `Price` — the buyer pays, the author receives the price minus the title's commission. Official\n content: the whole price goes to the title.\n- `Holding` — nothing is spent. `WhileHeld`: checked on **every download**, spend the key and access\n is gone (no license is written). `UnlockOnce`: checked once, then a permanent license. `Match: All`\n needs every requirement, `Any` one of them. Items may carry `MinLevel`.\n\n## Client API (`client.workshop`)\n\n```ts\nconst defs = await client.workshop.getDefinitions();\nconst page = await client.workshop.browse({\n contentType: \"voxelcraft.world\",\n sort: \"Popular\",\n});\nconst card = await client.workshop.getContent(contentID); // + per-option availability\n\nconst pub = await client.workshop.publish({\n contentType: \"voxelcraft.world\",\n files: [\n {\n role: \"main\",\n contentType: \"application/json\",\n data: JSON.stringify(save),\n },\n ],\n thumbnail: { contentType: \"image/webp\", data: webpBlob },\n title: \"Frost Keep\",\n tags: [\"castle\"],\n visibility: \"Public\", // Public | Unlisted | Friends\n access: [{ Mode: \"Free\" }],\n});\n\nconst got = await client.workshop.acquire(contentID, option); // pass the option you SHOWED\nconst dl = await client.workshop.downloadFiles(contentID); // bytes of every file\n```\n\n- `publish` does declare → upload straight to storage by signed URLs → verify and release. With\n `contentID` it uploads a new revision of your own publication. A file from `client.ai` can be\n published by URL: `{ role, contentType, sourceAssetUrl }` — the server copies it.\n- **`acquire(contentID, option)` sends `ExpectedPrice = option.Price`.** If the author changed the\n price meanwhile the server refuses instead of charging a price the player never saw — re-read the\n card and ask again. Acquiring twice is safe: the second answer says `AlreadyOwned`, nothing charged.\n- `getDownload` / `downloadFiles` return short-lived signed links — fetch right away, don't store them.\n- Also: `updateContent`, `unpublish` (buyers keep access), `getMyContent`, `getMyLicenses`,\n `getMyFavorites`, `getCreatorProfile`, `getCollection`.\n- ⚠ **Likes, favorites, following an author, comments and reports are `client.social`, not\n `client.workshop`** (see the `social-system` skill): `client.social.like(\"WorkshopItem\", id)`,\n `favorite(\"WorkshopItem\", id)`, `follow(\"User\", creatorUserID)`,\n `report(\"WorkshopItem\", id, \"Broken\")`, `addComment(\"WorkshopItem\", id, text)`. The Workshop\n rules still apply to them (gate, visibility, \"can't like your own\", \"you may report what you\n already got\"). Cards keep `Stats.Likes`/`Favorites` and `Liked`/`Favorited`; the author's follower\n count and \"am I following\" come from `getCreatorProfile` or `client.social.getEntityStates(\"User\", …)`.\n- Events: `workshop:definitionsLoaded | published | updated | acquired` (reactions emit\n `social:entityStateChanged`).\n\n## Plugging a game into the `workshop` module\n\n```ts\nsetup(ctx) {\n ctx.content.registerType({\n type: \"level\", // = key of Workshop.ContentTypes\n label: \"Level\", icon: \"🧩\", modeId: \"my-game\",\n listLocal: async () => myLevels.map((l) => ({ id: l.id, name: l.name })),\n capture: async (id) => ({ files: [{ role: \"main\", contentType: \"application/json\",\n data: JSON.stringify(load(id)) }], suggestedTitle: load(id).name }),\n open: async (c) => startLevel(JSON.parse(new TextDecoder().decode(c.files[0].data))),\n });\n}\n```\n\nThe module is a tab of the lobby. It is not part of the base: a game that registers content lists\n`\"workshop\"` in its `uses` (module.meta.json), and the platform installs it together with that game.\nIt stays hidden until some game registers a content type. It lists your local items, publishes them, and after `open`\nswitches to `modeId`. A type with no handler is still browsable and acquirable — it just can't be\npublished or opened from the game. Contract details: **idosgames-module-contract**.\n\n## Don't\n\n- Don't gate access on the client — show `getContent`'s option availability and let `acquire` decide.\n- Don't write a \"license\" of your own for `WhileHeld` content: access must follow the player's\n inventory.\n- Don't cache signed URLs or put private files in the public config.\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|