@idosgames/mcp 0.1.22 → 0.1.23
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/package.json +1 -1
- package/registry/host.json +2 -2
- package/registry/index.json +122 -122
- package/registry/modules/ai.json +5 -5
- package/registry/modules/board-game.json +9 -9
- package/registry/modules/boosts.json +7 -7
- package/registry/modules/character.json +6 -6
- package/registry/modules/chat.json +3 -3
- package/registry/modules/collections.json +5 -5
- package/registry/modules/coop-events.json +5 -5
- package/registry/modules/craft.json +5 -5
- package/registry/modules/deals.json +5 -5
- package/registry/modules/events.json +5 -5
- package/registry/modules/friends.json +5 -5
- package/registry/modules/idle-rpg.json +8 -8
- package/registry/modules/inventory.json +5 -5
- package/registry/modules/invite.json +5 -5
- package/registry/modules/leaderboard.json +5 -5
- package/registry/modules/lootboxes.json +6 -6
- package/registry/modules/mailbox.json +5 -5
- package/registry/modules/marketplace.json +6 -6
- package/registry/modules/mini-game.json +3 -3
- package/registry/modules/multiplayer.json +7 -7
- package/registry/modules/premium.json +5 -5
- package/registry/modules/profile.json +3 -3
- package/registry/modules/pvp.json +7 -7
- package/registry/modules/quests.json +5 -5
- package/registry/modules/rewards.json +5 -5
- package/registry/modules/season.json +5 -5
- package/registry/modules/store.json +5 -5
- package/registry/modules/tutorials.json +3 -3
- package/registry/modules/voxelcraft.json +6 -6
- package/registry/modules/workshop.json +3 -3
- package/registry/skills/blockchain-system.json +1 -1
- package/registry/skills/community-marketing-system.json +1 -1
- package/registry/skills/idosgames-getting-started.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +1 -1
- package/registry/skills/mailbox-system.json +1 -1
- package/registry/skills/match-system.json +2 -2
- package/registry/skills/multiplayer-realtime.json +2 -2
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "match-system",
|
|
3
3
|
"description": "Build a PvP match system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.match (MatchService): create/update/cancel an open PvP match offer with an entry cost, resolve an instant server-side battle, save a client-side battle strategy (attack/defense zone picks per round), and list/paginate the player's own matches or matches available to join. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants PvP matchmaking, duel/arena screens, entry-cost battles, attack-zone strategy pickers, battle logs, or otherwise touches client.match, MatchService, MatchModels, PvPMatch, BattleStepConfig, InstantBattleResponse, or BattleResult — even if they don't name the module explicitly.",
|
|
4
|
-
"content": "---\nname: match-system\ndescription: >-\n Build a PvP match system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.match (MatchService): create/update/cancel an\n open PvP match offer with an entry cost, resolve an instant server-side\n battle, save a client-side battle strategy (attack/defense zone picks per\n round), and list/paginate the player's own matches or matches available to\n join. Use this whenever the user is working in the iDosGames TS SDK or its\n game templates (board-game, idle-rpg) and wants PvP matchmaking, duel/arena\n screens, entry-cost battles, attack-zone strategy pickers, battle logs, or\n otherwise touches client.match, MatchService, MatchModels, PvPMatch,\n BattleStepConfig, InstantBattleResponse, or BattleResult — even if they\n don't name the module explicitly.\n---\n\n# Match system (iDosGames TS SDK)\n\nThe Match module is the SDK's PvP layer: one player creates a match offer\ncommitting an entry cost (currency, stackable items, and/or event tokens), an\nopponent joins (or is targeted directly), and the battle is resolved\n**instantly, server-side** — there's no live turn exchange over the wire. The\nclient's only pre-battle input is a `BattleStrategy`: a fixed sequence of\nattack/defense zone picks (Head/Torso/Legs) that both fighters' stats and gear\nfeed into a server-owned combat formula. You never compute damage, hit\nchance, or a winner yourself — you call a method, check the result, and\nrender the returned `BattleResult` (or the error).\n\nThis skill is for **using** the production `MatchService`, not for porting or\nreimplementing the combat engine. If a call is rejected, that's the backend\nenforcing a rule (entry policy, anti-abuse limit, rate limit) — surface the\nerror, don't try to reproduce the check client-side.\n\n## Mental model\n\n- A **match** (`PvPMatch`) is an offer: creator commits an `Entry` cost,\n picks a `RuleID` (which combat/economy config applies) and optionally a\n `CharacterID` and `BattleStrategy`, and optionally targets one opponent\n (`TargetUserID`) or leaves it public. Status moves\n `Open → Completed` (or `Cancelled`) — there is no persisted `InProgress`\n step; a join and its battle resolve in the same call.\n- **Instant battle** is the resolution step: given a `MatchID` plus your\n `CharacterID` and `BattleStrategy`, the server runs the fight immediately\n and returns a `BattleResult` — winner, loser, entry/reward bundles, and a\n full round-by-round `BattleLog`. This is the \"PvP\" combat resolution;\n nothing about it is real-time/turn-based on the client.\n- **Battle strategy** (`BattleStepConfig[]`) is the only player-authored\n combat input: a list of `{ AttackTarget, DefenseTarget }` picks (each a\n `\"Head\" | \"Torso\" | \"Legs\"`), one per round, capped at 10 steps server-side\n (extra steps are silently dropped) and cycled by index if the battle runs\n longer. You can attach a strategy inline to\n `createMatch`/`updateMatch`/`instantBattle`, or persist a default one\n independently with `saveStrategy` (cached at\n `client.data.user.state?.Match?.PvPBattleStrategy`). **If neither a request\n strategy nor a saved one is available, the server generates a random\n 3-step strategy** — don't assume a battle without an explicit strategy\n fails; it just fights randomly.\n- Rewards can be **single-party** (`Resources: ResourceOperation`, e.g. on\n `createMatch`/`cancelMatch` where only your own balance moves, and on a\n **draw**, which refunds the creator) or **dual-party**\n (`ResourcesDual: ResourceDualPartyResult`, on a **decisive** `instantBattle`,\n since both fighters' balances change from one call). The SDK always\n applies only **your** side to the local cache — see Gotchas.\n- Terminology note: the SDK and backend deliberately use **Entry** (the cost\n to participate) and **NetReward** (the winner's payout after burn) — not\n \"stake\"/\"wager\". Mirror that vocabulary in your own UI copy.\n\nFor the full config shape (entry whitelists, creation costs/limits, the\ncombat stat-mapping and formula blocks) and the full response/state shapes\n(`PvPMatch`, `BattleResult`, `PlayerBattleProfile`), see\n[references/data-model.md](references/data-model.md).\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst match = client.match; // the MatchService\n```\n\nEvery match method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before\ntouching `result.data`. `reason` is one of `\"client\"` (bad local args, e.g.\nmissing `matchID`), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint\nagain inside the default throttle window), `\"connection\"` (transient, offer\nRetry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason straight from the\nbackend, e.g. `\"Entry is required.\"`, `\"Unknown entry currency 'gems'.\"`,\n`\"You have too many open matches (3/3).\"`).\n\n| Method | Purpose | `data` on success |\n| -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |\n| `createMatch(entry, ruleID, characterID?, battleStrategy?, targetUserID?)` | Open a new match: commit `entry`, pick a rule/character, optionally target one opponent. | `CreateMatchResponse` (`MatchID`, `Status`, `Entry`, `Resources`) |\n| `updateMatch(matchID, fields?)` | Edit an **open** match's own fields (privacy, rule, character, strategy) before anyone joins. | `UpdateMatchResponse` (`Match`) |\n| `cancelMatch(matchID)` | Cancel your own open match; refunds the entry cost (+ creation cost, if configured). | `CancelMatchResponse` (`Status`, `Resources`) |\n| `instantBattle(matchID, ruleID, characterID?, battleStrategy?)` | Join an open match and resolve it immediately server-side. | `InstantBattleResponse` (`Battle`, `Resources` or `ResourcesDual`) |\n| `saveStrategy(battleStrategy)` | Persist a default battle strategy independent of any specific match. | `SuccessResponse` |\n| `getMyMatches(page?, pageSize?, statuses?)` | Paged list of matches you created or joined, optionally filtered by status. | `MatchesPageResponse` |\n| `getAvailableMatches(page?, pageSize?, onlyPublic?)` | Paged list of open matches you could join. | `MatchesPageResponse` |\n| `getDefinitions()` | Load the title's Match config (rules, entry whitelist, combat formulas). | `GetMatchDefinitionsResponse` (`MatchDefinitions`) |\n\n`updateMatch`'s `fields` is `{ targetUserID?, clearTargetUser?, ruleID?,\ncharacterID?, battleStrategy? }` — all optional, only the fields you pass are\nchanged; the entry cost itself can never be edited (cancel and recreate to\nchange it). `clearTargetUser: true` makes a private match public again and\ntakes priority over `targetUserID` if both are set. `getMyMatches`/\n`getAvailableMatches` default to `page = 0, pageSize = 20` (server clamps\n`pageSize` to 50); `statuses` filters `getMyMatches` to specific\n`MatchStatus` values (`\"Open\" | \"InProgress\" | \"Cancelled\" | \"Completed\"` —\n`\"InProgress\"` is defined but never actually persisted by this backend);\n`onlyPublic` restricts `getAvailableMatches` to untargeted matches (targeted\nmatches you're the target of are included by default).\n\n`entry` for `createMatch` is a `ResourceBundle` (`{ Entries?, EventTokens? }`)\n— the exact shape shared with Currency/Store, not a bare number. `ruleID`\nmust exist in the title's `MatchDefinitions` (or be the built-in\n`\"InstantBattle1v1\"`, always available even with no title config). Passing a\n`ruleID` to `instantBattle` that doesn't match the offer's own `RuleID` fails\nwith `\"RuleID mismatch.\"` — always read `ruleID` off the listing you're\njoining, don't hardcode it.\n\nOn success, `createMatch`/`cancelMatch`/`instantBattle` also **mirror the\nconfirmed resource change into the cache** (balances update immediately —\nread them from `client.data`), and every method emits an event. `saveStrategy`\nmirrors the strategy into `client.data.user.state?.Match?.PvPBattleStrategy`.\n`getDefinitions()` mirrors into\n`client.data.config.getSection<MatchDefinitions>(\"Match\")`.\n\n## Events\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `match:created` → `CreateMatchResponse`\n- `match:updated` → `UpdateMatchResponse`\n- `match:cancelled` → `CancelMatchResponse`\n- `match:instantBattleCompleted` → `InstantBattleResponse`\n- `match:strategySaved` → `BattleStepConfig[]`\n- `match:myMatchesLoaded` → `MatchesPageResponse`\n- `match:availableMatchesLoaded` → `MatchesPageResponse`\n- `match:definitionsLoaded` → `GetMatchDefinitionsResponse`\n\nThe coarse `user:matchUpdated` (and umbrella `user:anyUpdated`) also fire\nwhenever `saveStrategy` patches the cache — handy for a \"re-render everything\"\nhook. Note it does **not** fire on `createMatch`/`cancelMatch`/`instantBattle`;\nthose only touch the resource-balance cache (which fires its own\n`user:inventoryUpdated`/`user:virtualCurrencyUpdated`/`user:anyUpdated`) and\ntheir own `match:*` event above — there's no cached `PvPMatch` list kept in\nsync automatically, so re-fetch with `getMyMatches`/`getAvailableMatches`\nafter acting instead of assuming the list you already loaded is current.\n\n```ts\nconst off = client.on(\"match:instantBattleCompleted\", (r) => {\n console.log(r.Battle?.WinnerUserID === myUserID ? \"Won!\" : \"Lost.\");\n});\n// later: off();\n```\n\n## Recipes\n\n### Save a default battle strategy\n\n```ts\nconst res = await client.match.saveStrategy([\n { AttackTarget: \"Head\", DefenseTarget: \"Torso\" },\n { AttackTarget: \"Legs\", DefenseTarget: \"Head\" },\n]);\nif (!res.ok) return showError(res.error);\n\n// cached immediately:\nclient.data.user.state?.Match?.PvPBattleStrategy;\n```\n\nEach entry is one round's picks (max 10; extras are dropped server-side).\nThis is independent of any match — a good default to fall back to when the\nplayer hasn't set a per-match strategy. Skipping this entirely is valid too:\nthe server falls back to a random strategy when a battle needs one and none\nwas ever saved or passed inline.\n\n### Create an open match, then let someone join and resolve it\n\n```ts\nconst created = await client.match.createMatch(\n { Entries: [{ Type: \"VirtualCurrency\", CurrencyID: \"coins\", Amount: 100 }] },\n \"InstantBattle1v1\",\n \"Knight\",\n [{ AttackTarget: \"Head\", DefenseTarget: \"Torso\" }],\n);\nif (!created.ok) return showError(created.error); // e.g. can't afford, over MaxOpenMatches\nconst matchID = created.data.MatchID; // Status is \"Open\"\n\n// ...opponent joins out-of-band (server flow)...\n\nconst battle = await client.match.instantBattle(\n matchID,\n \"InstantBattle1v1\",\n \"Knight\",\n [{ AttackTarget: \"Head\", DefenseTarget: \"Torso\" }],\n);\nif (!battle.ok) return showError(battle.error);\nbattle.data.Battle?.WinnerUserID;\nbattle.data.Battle?.BattleLog; // round-by-round hits, for a replay UI\n```\n\nOmit `targetUserID` to leave the match open to anyone; pass it to challenge a\nspecific player directly. Creating a match requires you to already have an\nactivated character to fight with — `createMatch` fails up front with\n`\"You don't have a character to create a match with.\"` rather than letting\nyou open a match nobody (including you) could ever resolve.\n\n### Cancel an open match\n\n```ts\nconst res = await client.match.cancelMatch(matchID);\nif (!res.ok) return showError(res.error); // e.g. \"Only an open match can be cancelled.\"\n// entry cost (and creation cost, if the rule refunds it) is already refunded in the cache\n```\n\nOnly works while the match is still `\"Open\"` (nobody has joined). Once a\nmatch is claimed by a joiner and resolved, cancelling is rejected with\n`\"Only an open match can be cancelled.\"` — there's a race window (someone can\njoin between your UI showing \"Open\" and your cancel landing); treat that\nrejection as \"too late,\" not a bug.\n\n### Update a match's privacy before anyone joins\n\n```ts\n// make a public match private, targeting one player:\nawait client.match.updateMatch(matchID, { targetUserID: \"player-42\" });\n\n// make it public again:\nawait client.match.updateMatch(matchID, { clearTargetUser: true });\n```\n\nOnly fields you pass are changed; this only succeeds while the match is\nstill open and yours (`\"Only the match creator can edit it.\"` /\n`\"Only an open match can be edited.\"` otherwise).\n\n### List matches to join, then list my own history\n\n```ts\nconst available = await client.match.getAvailableMatches(0, 20, true); // public only\nif (available.ok) {\n for (const m of available.data.Matches ?? []) {\n // m.Entry, m.RuleID, m.CreatorID — render as a joinable card\n }\n available.data.HasMore; // paginate: getAvailableMatches(page + 1, ...)\n}\n\nconst mine = await client.match.getMyMatches(0, 20, [\"Open\", \"InProgress\"]);\n```\n\n### Reading a dual-party instant-battle result\n\n```ts\nconst battle = await client.match.instantBattle(matchID, \"InstantBattle1v1\");\nif (battle.ok) {\n const d = battle.data;\n if (d.Resources) {\n // single-party shape — this was a draw: only the creator's refund moved.\n } else if (d.ResourcesDual) {\n // decisive result; both sides changed. The SDK already applied only\n // *your* half to the cache (matched against your userID). Read the\n // other side purely for display, e.g. an opponent summary — don't\n // apply it yourself.\n const mine =\n d.ResourcesDual.FromUserID === myUserID\n ? d.ResourcesDual.FromResult\n : d.ResourcesDual.ToResult;\n }\n d.Battle?.NetReward; // winner's payout bundle after burn (null on a draw)\n}\n```\n\n## Gotchas\n\n- **Instant battle resolves immediately — there's no live turn exchange.**\n `BattleStrategy` is submitted up front (per-round zone picks); the server\n runs every round in one call and hands back the full `BattleLog`. Build a\n \"replay\" animation client-side off the log rather than expecting a\n round-by-round network exchange.\n- **No strategy set is not an error — it's a random fighter.** If you call\n `instantBattle`/`createMatch` without `battleStrategy` and the player never\n called `saveStrategy`, the server rolls a random 3-step strategy for that\n side. Don't rely on this for a real match — always send a real strategy (or\n save a default) once your UI has one, but don't treat its absence as a\n client-side validation error either.\n- **`ResourcesDual` only applies your own side.** `instantBattle` matches\n `FromUserID`/`ToUserID` (the loser/winner respectively) against the logged-in\n user and applies only the matching `FromResult`/`ToResult` to the cache —\n the other fighter's balance change is informational only (e.g. for a\n results screen), not something you apply locally. On a **draw**, only\n `Resources` is set (a refund to the creator) and `ResourcesDual` is null.\n- **No auto-synced match list.** There's no cached `Record<MatchID, PvPMatch>`\n kept current by events — `match:created`/`match:updated`/`match:cancelled`\n just report that one call's result. Re-fetch `getMyMatches`/\n `getAvailableMatches` to refresh a list view after any mutation.\n- **`UserMatchState` (`client.data.user.state?.Match`) is thin.** It only\n holds `PvPBattleStrategy` and `CreationLimits` — there is no cached list of\n the player's matches there; use `getMyMatches` for that. Also note this\n slot is only ever written by `saveStrategy` in this SDK — nothing hydrates\n it from the login bootstrap, so until the player calls `saveStrategy` (or\n `createMatch`/`updateMatch` with an inline strategy) once this session,\n `PvPBattleStrategy` reads as `undefined` even if the backend has a\n previously-saved strategy for them. `CreationLimits` is populated by the\n backend on `createMatch` but nothing in this SDK reads it back into the\n cache yet — treat it as informational/future.\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations — a double-clicked \"Create\n match\" or \"Fight\" can charge/resolve twice. Disable the control while a\n call is in flight.\n- **Anti-abuse is server-enforced, not just a UI nicety.** A title can cap\n simultaneously open offers (`MaxOpenMatches`), rate-limit creation\n (cooldown + daily cap via `Limits`), and cap completed matches against the\n same opponent per day (`MaxMatchesPerOpponentPerDay`, an anti win-trading\n measure). All three surface as ordinary `reason: \"server\"` rejections —\n show the message, don't try to pre-count client-side.\n- **`CharacterID` is optional but rule-dependent.** Whether a character (and\n its stats/gear) feeds the combat formula depends on the rule's\n `StatMapping`/`Formula` config — see\n [references/data-model.md](references/data-model.md). Character combat\n math and equipment are documented in\n `.claude/skills/character-system/SKILL.md`; don't re-derive Power/stat\n formulas here.\n- **Entry/reward bundles use the shared resource types.** `Entry`,\n `Resources`, `ResourcesDual` all build on `ResourceBundle`/\n `ResourceOperation`/`ResourceConsume` — fully documented in\n `.claude/skills/currency-system/SKILL.md`; cross-link rather than\n re-deriving.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — the full\n`MatchDefinitions` config shape (entry whitelist, creation cost/limits, combat\nstat-mapping and formula blocks), the `PvPMatch`/`BattleResult`/\n`PlayerBattleProfile` response shapes, the net-reward/burn formula, and the\nbattle-log entry shape. Read it when building config-driven UI (entry\npickers, cost previews) or a battle-log replay screen.\n",
|
|
4
|
+
"content": "---\nname: match-system\ndescription: >-\n Build a PvP match system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.match (MatchService): create/update/cancel an\n open PvP match offer with an entry cost, resolve an instant server-side\n battle, save a client-side battle strategy (attack/defense zone picks per\n round), and list/paginate the player's own matches or matches available to\n join. Use this whenever the user is working in the iDosGames TS SDK or its\n game templates (board-game, idle-rpg) and wants PvP matchmaking, duel/arena\n screens, entry-cost battles, attack-zone strategy pickers, battle logs, or\n otherwise touches client.match, MatchService, MatchModels, PvPMatch,\n BattleStepConfig, InstantBattleResponse, or BattleResult — even if they\n don't name the module explicitly.\n---\n\n# Match system (iDosGames TS SDK)\n\nThe Match module is the SDK's PvP layer: one player creates a match offer\ncommitting an entry cost (currency, stackable items, and/or event tokens), an\nopponent joins (or is targeted directly), and the battle is resolved\n**instantly, server-side** — there's no live turn exchange over the wire. The\nclient's only pre-battle input is a `BattleStrategy`: a fixed sequence of\nattack/defense zone picks (Head/Torso/Legs) that both fighters' stats and gear\nfeed into a server-owned combat formula. You never compute damage, hit\nchance, or a winner yourself — you call a method, check the result, and\nrender the returned `BattleResult` (or the error).\n\nThis skill is for **using** the production `MatchService`, not for porting or\nreimplementing the combat engine. If a call is rejected, that's the backend\nenforcing a rule (entry policy, anti-abuse limit, rate limit) — surface the\nerror, don't try to reproduce the check client-side.\n\n## Mental model\n\n- A **match** (`PvPMatch`) is an offer: creator commits an `Entry` cost,\n picks a `RuleID` (which combat/economy config applies) and optionally a\n `CharacterID` and `BattleStrategy`, and optionally targets one opponent\n (`TargetUserID`) or leaves it public. Status moves\n `Open → Completed` (or `Cancelled`, or `Expired`) — there is no persisted\n `InProgress` step; a join and its battle resolve in the same call. A title\n may give offers a lifetime (`MatchCreationSettings.OfferLifetimeSeconds`):\n past `match.ExpiresAt` the offer can't be joined or edited, and within a\n minute the engine refunds the creator's entry and marks it `Expired` — no\n client call needed. Show an `Open` match whose `ExpiresAt` has passed as\n expired, not as waiting.\n- **Instant battle** is the resolution step: given a `MatchID` plus your\n `CharacterID` and `BattleStrategy`, the server runs the fight immediately\n and returns a `BattleResult` — winner, loser, entry/reward bundles, and a\n full round-by-round `BattleLog`. This is the \"PvP\" combat resolution;\n nothing about it is real-time/turn-based on the client.\n- **Battle strategy** (`BattleStepConfig[]`) is the only player-authored\n combat input: a list of `{ AttackTarget, DefenseTarget }` picks (each a\n `\"Head\" | \"Torso\" | \"Legs\"`), one per round, capped at 10 steps server-side\n (extra steps are silently dropped) and cycled by index if the battle runs\n longer. You can attach a strategy inline to\n `createMatch`/`updateMatch`/`instantBattle`, or persist a default one\n independently with `saveStrategy` (cached at\n `client.data.user.state?.Match?.PvPBattleStrategy`). **If neither a request\n strategy nor a saved one is available, the server generates a random\n 3-step strategy** — don't assume a battle without an explicit strategy\n fails; it just fights randomly.\n- Rewards can be **single-party** (`Resources: ResourceOperation`, e.g. on\n `createMatch`/`cancelMatch` where only your own balance moves, and on a\n **draw**, which refunds the creator) or **dual-party**\n (`ResourcesDual: ResourceDualPartyResult`, on a **decisive** `instantBattle`,\n since both fighters' balances change from one call). The SDK always\n applies only **your** side to the local cache — see Gotchas.\n- Terminology note: the SDK and backend deliberately use **Entry** (the cost\n to participate) and **NetReward** (the winner's payout after burn) — not\n \"stake\"/\"wager\". Mirror that vocabulary in your own UI copy.\n\nFor the full config shape (entry whitelists, creation costs/limits, the\ncombat stat-mapping and formula blocks) and the full response/state shapes\n(`PvPMatch`, `BattleResult`, `PlayerBattleProfile`), see\n[references/data-model.md](references/data-model.md).\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst match = client.match; // the MatchService\n```\n\nEvery match method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok` before\ntouching `result.data`. `reason` is one of `\"client\"` (bad local args, e.g.\nmissing `matchID`), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint\nagain inside the default throttle window), `\"connection\"` (transient, offer\nRetry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason straight from the\nbackend, e.g. `\"Entry is required.\"`, `\"Unknown entry currency 'gems'.\"`,\n`\"You have too many open matches (3/3).\"`).\n\n| Method | Purpose | `data` on success |\n| -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |\n| `createMatch(entry, ruleID, characterID?, battleStrategy?, targetUserID?)` | Open a new match: commit `entry`, pick a rule/character, optionally target one opponent. | `CreateMatchResponse` (`MatchID`, `Status`, `Entry`, `Resources`) |\n| `updateMatch(matchID, fields?)` | Edit an **open** match's own fields (privacy, rule, character, strategy) before anyone joins. | `UpdateMatchResponse` (`Match`) |\n| `cancelMatch(matchID)` | Cancel your own open match; refunds the entry cost (+ creation cost, if configured). | `CancelMatchResponse` (`Status`, `Resources`) |\n| `instantBattle(matchID, ruleID, characterID?, battleStrategy?)` | Join an open match and resolve it immediately server-side. | `InstantBattleResponse` (`Battle`, `Resources` or `ResourcesDual`) |\n| `saveStrategy(battleStrategy)` | Persist a default battle strategy independent of any specific match. | `SuccessResponse` |\n| `getMyMatches(page?, pageSize?, statuses?)` | Paged list of matches you created or joined, optionally filtered by status. | `MatchesPageResponse` |\n| `getAvailableMatches(page?, pageSize?, onlyPublic?)` | Paged list of open matches you could join. | `MatchesPageResponse` |\n| `getDefinitions()` | Load the title's Match config (rules, entry whitelist, combat formulas). | `GetMatchDefinitionsResponse` (`MatchDefinitions`) |\n\n`updateMatch`'s `fields` is `{ targetUserID?, clearTargetUser?, ruleID?,\ncharacterID?, battleStrategy? }` — all optional, only the fields you pass are\nchanged; the entry cost itself can never be edited (cancel and recreate to\nchange it). `clearTargetUser: true` makes a private match public again and\ntakes priority over `targetUserID` if both are set. `getMyMatches`/\n`getAvailableMatches` default to `page = 0, pageSize = 20` (server clamps\n`pageSize` to 50); `statuses` filters `getMyMatches` to specific\n`MatchStatus` values (`\"Open\" | \"InProgress\" | \"Cancelled\" | \"Completed\" | \"Expired\"` —\n`\"InProgress\"` is defined but never actually persisted by this backend);\n`onlyPublic` restricts `getAvailableMatches` to untargeted matches (targeted\nmatches you're the target of are included by default).\n\n`entry` for `createMatch` is a `ResourceBundle` (`{ Entries?, EventTokens? }`)\n— the exact shape shared with Currency/Store, not a bare number. `ruleID`\nmust exist in the title's `MatchDefinitions` (or be the built-in\n`\"InstantBattle1v1\"`, always available even with no title config). Passing a\n`ruleID` to `instantBattle` that doesn't match the offer's own `RuleID` fails\nwith `\"RuleID mismatch.\"` — always read `ruleID` off the listing you're\njoining, don't hardcode it.\n\nOn success, `createMatch`/`cancelMatch`/`instantBattle` also **mirror the\nconfirmed resource change into the cache** (balances update immediately —\nread them from `client.data`), and every method emits an event. `saveStrategy`\nmirrors the strategy into `client.data.user.state?.Match?.PvPBattleStrategy`.\n`getDefinitions()` mirrors into\n`client.data.config.getSection<MatchDefinitions>(\"Match\")`.\n\n## Events\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `match:created` → `CreateMatchResponse`\n- `match:updated` → `UpdateMatchResponse`\n- `match:cancelled` → `CancelMatchResponse`\n- `match:instantBattleCompleted` → `InstantBattleResponse`\n- `match:strategySaved` → `BattleStepConfig[]`\n- `match:myMatchesLoaded` → `MatchesPageResponse`\n- `match:availableMatchesLoaded` → `MatchesPageResponse`\n- `match:definitionsLoaded` → `GetMatchDefinitionsResponse`\n\nThe coarse `user:matchUpdated` (and umbrella `user:anyUpdated`) also fire\nwhenever `saveStrategy` patches the cache — handy for a \"re-render everything\"\nhook. Note it does **not** fire on `createMatch`/`cancelMatch`/`instantBattle`;\nthose only touch the resource-balance cache (which fires its own\n`user:inventoryUpdated`/`user:virtualCurrencyUpdated`/`user:anyUpdated`) and\ntheir own `match:*` event above — there's no cached `PvPMatch` list kept in\nsync automatically, so re-fetch with `getMyMatches`/`getAvailableMatches`\nafter acting instead of assuming the list you already loaded is current.\n\n```ts\nconst off = client.on(\"match:instantBattleCompleted\", (r) => {\n console.log(r.Battle?.WinnerUserID === myUserID ? \"Won!\" : \"Lost.\");\n});\n// later: off();\n```\n\n## Recipes\n\n### Save a default battle strategy\n\n```ts\nconst res = await client.match.saveStrategy([\n { AttackTarget: \"Head\", DefenseTarget: \"Torso\" },\n { AttackTarget: \"Legs\", DefenseTarget: \"Head\" },\n]);\nif (!res.ok) return showError(res.error);\n\n// cached immediately:\nclient.data.user.state?.Match?.PvPBattleStrategy;\n```\n\nEach entry is one round's picks (max 10; extras are dropped server-side).\nThis is independent of any match — a good default to fall back to when the\nplayer hasn't set a per-match strategy. Skipping this entirely is valid too:\nthe server falls back to a random strategy when a battle needs one and none\nwas ever saved or passed inline.\n\n### Create an open match, then let someone join and resolve it\n\n```ts\nconst created = await client.match.createMatch(\n { Entries: [{ Type: \"VirtualCurrency\", CurrencyID: \"coins\", Amount: 100 }] },\n \"InstantBattle1v1\",\n \"Knight\",\n [{ AttackTarget: \"Head\", DefenseTarget: \"Torso\" }],\n);\nif (!created.ok) return showError(created.error); // e.g. can't afford, over MaxOpenMatches\nconst matchID = created.data.MatchID; // Status is \"Open\"\n\n// ...opponent joins out-of-band (server flow)...\n\nconst battle = await client.match.instantBattle(\n matchID,\n \"InstantBattle1v1\",\n \"Knight\",\n [{ AttackTarget: \"Head\", DefenseTarget: \"Torso\" }],\n);\nif (!battle.ok) return showError(battle.error);\nbattle.data.Battle?.WinnerUserID;\nbattle.data.Battle?.BattleLog; // round-by-round hits, for a replay UI\n```\n\nOmit `targetUserID` to leave the match open to anyone; pass it to challenge a\nspecific player directly. Creating a match requires you to already have an\nactivated character to fight with — `createMatch` fails up front with\n`\"You don't have a character to create a match with.\"` rather than letting\nyou open a match nobody (including you) could ever resolve.\n\n### Cancel an open match\n\n```ts\nconst res = await client.match.cancelMatch(matchID);\nif (!res.ok) return showError(res.error); // e.g. \"Only an open match can be cancelled.\"\n// entry cost (and creation cost, if the rule refunds it) is already refunded in the cache\n```\n\nOnly works while the match is still `\"Open\"` (nobody has joined). Once a\nmatch is claimed by a joiner and resolved, cancelling is rejected with\n`\"Only an open match can be cancelled.\"` — there's a race window (someone can\njoin between your UI showing \"Open\" and your cancel landing); treat that\nrejection as \"too late,\" not a bug.\n\n### Update a match's privacy before anyone joins\n\n```ts\n// make a public match private, targeting one player:\nawait client.match.updateMatch(matchID, { targetUserID: \"player-42\" });\n\n// make it public again:\nawait client.match.updateMatch(matchID, { clearTargetUser: true });\n```\n\nOnly fields you pass are changed; this only succeeds while the match is\nstill open and yours (`\"Only the match creator can edit it.\"` /\n`\"Only an open match can be edited.\"` otherwise).\n\n### List matches to join, then list my own history\n\n```ts\nconst available = await client.match.getAvailableMatches(0, 20, true); // public only\nif (available.ok) {\n for (const m of available.data.Matches ?? []) {\n // m.Entry, m.RuleID, m.CreatorID — render as a joinable card\n }\n available.data.HasMore; // paginate: getAvailableMatches(page + 1, ...)\n}\n\nconst mine = await client.match.getMyMatches(0, 20, [\"Open\", \"InProgress\"]);\n```\n\n### Reading a dual-party instant-battle result\n\n```ts\nconst battle = await client.match.instantBattle(matchID, \"InstantBattle1v1\");\nif (battle.ok) {\n const d = battle.data;\n if (d.Resources) {\n // single-party shape — this was a draw: only the creator's refund moved.\n } else if (d.ResourcesDual) {\n // decisive result; both sides changed. The SDK already applied only\n // *your* half to the cache (matched against your userID). Read the\n // other side purely for display, e.g. an opponent summary — don't\n // apply it yourself.\n const mine =\n d.ResourcesDual.FromUserID === myUserID\n ? d.ResourcesDual.FromResult\n : d.ResourcesDual.ToResult;\n }\n d.Battle?.NetReward; // winner's payout bundle after burn (null on a draw)\n}\n```\n\n## Gotchas\n\n- **Instant battle resolves immediately — there's no live turn exchange.**\n `BattleStrategy` is submitted up front (per-round zone picks); the server\n runs every round in one call and hands back the full `BattleLog`. Build a\n \"replay\" animation client-side off the log rather than expecting a\n round-by-round network exchange.\n- **No strategy set is not an error — it's a random fighter.** If you call\n `instantBattle`/`createMatch` without `battleStrategy` and the player never\n called `saveStrategy`, the server rolls a random 3-step strategy for that\n side. Don't rely on this for a real match — always send a real strategy (or\n save a default) once your UI has one, but don't treat its absence as a\n client-side validation error either.\n- **`ResourcesDual` only applies your own side.** `instantBattle` matches\n `FromUserID`/`ToUserID` (the loser/winner respectively) against the logged-in\n user and applies only the matching `FromResult`/`ToResult` to the cache —\n the other fighter's balance change is informational only (e.g. for a\n results screen), not something you apply locally. On a **draw**, only\n `Resources` is set (a refund to the creator) and `ResourcesDual` is null.\n- **No auto-synced match list.** There's no cached `Record<MatchID, PvPMatch>`\n kept current by events — `match:created`/`match:updated`/`match:cancelled`\n just report that one call's result. Re-fetch `getMyMatches`/\n `getAvailableMatches` to refresh a list view after any mutation.\n- **`UserMatchState` (`client.data.user.state?.Match`) is thin.** It only\n holds `PvPBattleStrategy` and `CreationLimits` — there is no cached list of\n the player's matches there; use `getMyMatches` for that. Also note this\n slot is only ever written by `saveStrategy` in this SDK — nothing hydrates\n it from the login bootstrap, so until the player calls `saveStrategy` (or\n `createMatch`/`updateMatch` with an inline strategy) once this session,\n `PvPBattleStrategy` reads as `undefined` even if the backend has a\n previously-saved strategy for them. `CreationLimits` is populated by the\n backend on `createMatch` but nothing in this SDK reads it back into the\n cache yet — treat it as informational/future.\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations — a double-clicked \"Create\n match\" or \"Fight\" can charge/resolve twice. Disable the control while a\n call is in flight.\n- **Anti-abuse is server-enforced, not just a UI nicety.** A title can cap\n simultaneously open offers (`MaxOpenMatches`; expired offers don't count),\n expire unaccepted offers (`OfferLifetimeSeconds`), rate-limit creation\n (cooldown + daily cap via `Limits`), and cap completed matches against the\n same opponent per day (`MaxMatchesPerOpponentPerDay`, an anti win-trading\n measure). All three surface as ordinary `reason: \"server\"` rejections —\n show the message, don't try to pre-count client-side.\n- **`CharacterID` is optional but rule-dependent.** Whether a character (and\n its stats/gear) feeds the combat formula depends on the rule's\n `StatMapping`/`Formula` config — see\n [references/data-model.md](references/data-model.md). Character combat\n math and equipment are documented in\n `.claude/skills/character-system/SKILL.md`; don't re-derive Power/stat\n formulas here.\n- **Entry/reward bundles use the shared resource types.** `Entry`,\n `Resources`, `ResourcesDual` all build on `ResourceBundle`/\n `ResourceOperation`/`ResourceConsume` — fully documented in\n `.claude/skills/currency-system/SKILL.md`; cross-link rather than\n re-deriving.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — the full\n`MatchDefinitions` config shape (entry whitelist, creation cost/limits, combat\nstat-mapping and formula blocks), the `PvPMatch`/`BattleResult`/\n`PlayerBattleProfile` response shapes, the net-reward/burn formula, and the\nbattle-log entry shape. Read it when building config-driven UI (entry\npickers, cost previews) or a battle-log replay screen.\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Match data model — reference\n\nFull shape of the config (`MatchDefinitions`), the match/battle state, and the\nrequest/response types. All of these are **strictly typed in the SDK** —\n`MatchDefinitions` and every nested block (`InstantBattleRule`,\n`MatchEntrySettings`, `MatchStatFormula`, …) are exported from\n`@idosgames/core`, so `getDefinitions()` and\n`getSection<MatchDefinitions>(\"Match\")` give you concrete types, not\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds\nlater still round-trips. Field names are PascalCase (straight from the\nbackend JSON).\n\nTerminology: the backend consistently calls the cost to participate **Entry**\nand the winner's payout **NetReward** — there is no \"stake\"/\"wager\" anywhere\nin the Match model. Use that vocabulary in any UI copy you generate.\n\n## Contents\n\n- [Player state](#player-state) — `UserMatchState`\n- [Match (offer)](#match-offer) — `PvPMatch`\n- [Battle result](#battle-result) — `BattleResult`, `BattleLogEntry`, `PlayerBattleProfile`, `FighterStats`\n- [Config: MatchDefinitions](#config-matchdefinitions) — what `getDefinitions()` returns\n- [InstantBattleRule](#instantbattlerule)\n- [Combat formulas](#combat-formulas) — `CombatStatMapping`, `MatchStatFormula`, `FormulaTerm`/`FormulaFactor`, the stat-calculation layers\n- [Entry & creation settings](#entry--creation-settings) — whitelist, anti-abuse limits\n- [Net reward / burn formula](#net-reward--burn-formula)\n- [Battle strategy resolution](#battle-strategy-resolution) — random fallback, step cap\n- [Request shape](#request-shape)\n\n---\n\n## Player state\n\nCached at `client.data.user.state?.Match` as `UserMatchState`. Populated\n**only** by `saveStrategy` in this SDK (see Gotchas in SKILL.md) — nothing\nhydrates it at login.\n\n```ts\ninterface UserMatchState {\n PvPBattleStrategy?: BattleStepConfig[];\n CreationLimits?: UserMatchCreationLimitState | null;\n}\n\ninterface UserMatchCreationLimitState {\n LastCreatedAt?: string; // ISO timestamp of the last createMatch call\n DailyCreations?: number; // counter toward Limits in MatchCreationSettings\n DailyResetUtc?: string; // next UTC midnight reset\n}\n\ninterface BattleStepConfig {\n AttackTarget: \"Head\" | \"Torso\" | \"Legs\";\n DefenseTarget: \"Head\" | \"Torso\" | \"Legs\";\n}\n```\n\n`CreationLimits` mirrors the cooldown/daily-cap counters the backend keeps\nserver-side (`Match.CreationLimits` on the player document, written by\n`MatchHelpers.BuildCreationLimitPatches` inside the `createMatch` transaction)\nfor a rule's `Creation.Limits` (a `LimitSpec`). It's exposed on the wire type\nfor a client to eventually show \"next match available in…\" UI, but nothing in\n`MatchService` currently reads it back into this cache slot — treat it as\ninformational/future until a response actually populates it for you.\n\n---\n\n## Match (offer)\n\n`PvPMatch` — one open/resolved match, returned inside `MatchesPageResponse`\nand `UpdateMatchResponse`.\n\n```ts\ninterface PvPMatch {\n MatchID: string;\n TitleID?: string;\n RuleID?: string;\n CreatedAt?: string;\n CreatorID?: string;\n CreatorCharacterID?: string;\n CreatorStrategy?: BattleStepConfig[]; // stripped from GetAvailableMatches listings\n TargetUserID?: string; // set = private/targeted challenge; absent = public\n Entry?: ResourceBundle; // the creator's entry cost\n CreationCostPaid?: ResourceBundle; // separate from Entry — the fee to open the match\n RefundCreationCostOnCancel?: boolean;\n JoinedByUserID?: string;\n JoinedByCharacterID?: string;\n JoinedAt?: string;\n Status?: \"Open\" | \"InProgress\" | \"Cancelled\" | \"Completed\";\n WinnerUserID?: string; // absent/null on a draw\n CompletedAt?: string;\n IsRewardDistributed?: boolean;\n RewardDistributedAt?: string;\n}\n```\n\n`Entry` (the cost to participate) and `CreationCostPaid` (the fee to list the\nmatch) are tracked separately — cancelling refunds the entry cost always, and\nthe creation fee only when `RefundCreationCostOnCancel` is true.\n\n`Status` never actually passes through `\"InProgress\"` in this backend: a join\nresolves the battle synchronously in the same call, so a match goes directly\n`Open → Completed` (backend: `MatchDatabase.TryFinalizeOpenMatchInSessionAsync`\nsets `Completed` straight from an `Open` filter). `\"InProgress\"` exists in the\nenum for forward-compat / other match modes, not for instant-battle.\n\n`GetAvailableMatches` listings project out `CreatorStrategy` (backend:\n`MatchDatabase.ReadAvailableMatchesPagedAsync` excludes it) — you only see a\nmatch's strategy from `getMyMatches` or after you've fetched the match some\nother way; it isn't needed to join, since your own strategy is what you send\nto `instantBattle`.\n\n---\n\n## Battle result\n\nReturned inside `InstantBattleResponse.Battle`.\n\n```ts\ninterface BattleResult {\n WinnerUserID?: string; // absent on a draw\n LoserUserID?: string; // absent on a draw\n Entry?: ResourceBundle; // one side's entry cost that was in play\n NetReward?: ResourceBundle; // winner's payout after burn; null on a draw\n BattleLog?: BattleLogEntry[]; // full round-by-round hit list\n IsDraw?: boolean;\n P1BattleProfile?: PlayerBattleProfile; // the match creator\n P2BattleProfile?: PlayerBattleProfile; // the joiner (the caller of instantBattle)\n}\n\ninterface BattleLogEntry {\n RoundIndex?: number; // 1-based\n AttackerID?: string;\n DefenderID?: string;\n AttackZone?: \"Head\" | \"Torso\" | \"Legs\";\n DefenseZone?: \"Head\" | \"Torso\" | \"Legs\";\n HitType?: \"Hit\" | \"Critical\" | \"Block\" | \"Dodge\";\n DamageDealt?: number; // 0 on Dodge; reduced (BlockDamageMultiplier) on Block\n DefenderHpRemaining?: number; // floored at 0\n}\n\ninterface PlayerBattleProfile {\n UserID?: string;\n SelectedCharacterID?: string;\n SelectedCharacter?: CharacterModel; // see character-system skill\n BattleStrategy?: BattleStepConfig[]; // the strategy actually used (inline, saved, or random)\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // always null on the wire in practice\n Stats?: FighterStats; // final computed combat stats used for this fight\n}\n\ninterface FighterStats {\n MaxHp?: number; // starting HP, for a results-screen HP bar\n CurrentHp?: number; // HP at the end of the fight\n Damage?: number;\n AttackSpeed?: number; // higher acts first each round; a tie coin-flips\n CritChance?: number; // 0..MaxCritChance\n CritMultiplier?: number;\n Armor?: number; // meaning depends on ArmorMode: damage units (Flat) or a 0..MaxArmorReduction fraction (PercentReduction)\n DodgeChance?: number; // 0..MaxDodgeChance\n}\n```\n\nEach round, both fighters act in `AttackSpeed` order (ties broken by a random\ncoin flip — backend `PvPBattleEngine.SimulateBattle`); if the first attacker's\nhit drops the defender to 0 HP, the defender does not get to act that round.\n`AttackZone`/`DefenseZone` per log entry come from each side's\n`BattleStrategy`, indexed `(RoundIndex - 1) % strategy.length` — a\nstrategy shorter than the battle simply repeats from the top.\n\nPer-hit resolution order (backend `PvPBattleEngine.PerformAttack`):\n\n1. Roll crit: `isCrit = random() < attacker.CritChance`.\n2. `rawDamage = isCrit ? attacker.Damage * attacker.CritMultiplier : attacker.Damage`.\n3. Apply armor per `ArmorMode`:\n - `Flat` (default): `potentialDamage = max(MinHitDamage, rawDamage - defender.Armor)`.\n - `PercentReduction`: `potentialDamage = max(MinHitDamage, rawDamage * (1 - defender.Armor))`,\n with `Armor` already clamped to `[0, MaxArmorReduction]` in step 7 of `CalculateStats`.\n The `MinHitDamage` floor applies in both modes, so armor can never heal the defender.\n4. If `AttackZone === DefenseZone` → **Block**: `DamageDealt = round(potentialDamage * BlockDamageMultiplier, 2)`.\n5. Else if `random() < defender.DodgeChance` → **Dodge**: `DamageDealt = 0`.\n6. Else → **Hit** (or **Critical** if step 1 rolled true): `DamageDealt = potentialDamage`.\n\nThe battle ends when a fighter's HP hits 0, or after `MaxRounds` (default 50)\n— on timeout, higher remaining HP wins; exactly equal HP is a draw.\n\n`PlayerBattleProfile.UnstackableItems` is documented as an **input-only**\nlevel-scaling snapshot the engine used internally — the backend\n(`PvPBattleEngine.TrimProfile`) always strips it before the response reaches\nthe client, so expect it to read as `null`/absent in practice. Don't build UI\nthat depends on it being populated.\n\n`FighterStats` is the resolved combat stats each fighter fought with — read\nthese for a \"stat comparison\" results screen, but they are a snapshot of that\none fight, not a live/cached character stat.\n\n---\n\n## Config: MatchDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<MatchDefinitions>(\"Match\")`.\n\n```ts\ninterface MatchDefinitions {\n InstantBattle?: InstantBattleDefinitions;\n}\n\ninterface InstantBattleDefinitions {\n Rules?: Record<string, InstantBattleRule>; // key = RuleID\n Defaults?: InstantBattleSettings; // title-wide combat fallback\n EntryDefaults?: MatchEntrySettings; // title-wide entry-whitelist fallback\n CreationDefaults?: MatchCreationSettings; // title-wide creation cost/limits fallback\n}\n```\n\nCurrently `MatchDefinitions` has a single mode container, `InstantBattle` —\nthere's no other battle mode in the model today. If the title hasn't\nconfigured `Match` at all, `getDefinitions()` still returns a synthesized\nconfig with one rule, `\"InstantBattle1v1\"`, built from engine defaults\n(backend: `Match.GetDefinitions` / `MatchConfigResolver.BuiltInInstantBattle1v1`)\n— so there is always at least one valid `RuleID` to pass.\n\nResolution order for every block is **rule's own → title `Defaults` (or\n`EntryDefaults`/`CreationDefaults`) → engine built-in default** — the same\ninline-over-preset pattern the character module uses. `StatMapping` resolves\nper-field (each role can come from a different layer); `Combat`, `Entry`,\n`Creation` resolve as whole blocks (a rule that sets `Entry` gets none of the\ntitle's `EntryDefaults`, even for fields it left unset).\n\n---\n\n## InstantBattleRule\n\nOne entry in `InstantBattleDefinitions.Rules`, keyed by `RuleID` (the string\nyou pass as `ruleID` to `createMatch`/`updateMatch`/`instantBattle`).\n\n```ts\ninterface InstantBattleRule {\n RuleID?: string;\n DisplayName?: string;\n Description?: string;\n Economy?: MatchEconomySettings;\n Entry?: MatchEntrySettings;\n Creation?: MatchCreationSettings;\n Settings?: InstantBattleSettings;\n}\n\ninterface MatchEconomySettings {\n BurnRate?: number; // fraction of the *reward* burned when paid to the winner; default 0.05 (5%)\n}\n```\n\n---\n\n## Combat formulas\n\n`InstantBattleSettings` (a rule's `Settings`, or the title-wide `Defaults`)\nconfigures how a fighter's `FighterStats` are derived for a battle.\n\n```ts\ninterface InstantBattleSettings {\n StatMapping?: CombatStatMapping;\n Combat?: MatchCombatSettings;\n Formula?: MatchStatFormula;\n}\n\ninterface CombatStatMapping {\n HealthStatID?: string; // which character StatID feeds MaxHp. Default: \"Health\"\n DamageStatID?: string; // Default: \"Damage\"\n ArmorStatID?: string; // Default: \"Armor\"\n AttackSpeedStatID?: string; // Default: \"AttackSpeed\"\n CritChanceStatID?: string; // Default: \"CritChance\"\n CritDamageStatID?: string; // Default: \"CritDamage\"\n DodgeStatID?: string; // Default: \"Speed\"\n AllMightStatID?: string; // overall percent multiplier stat. Default: \"AllMight\"\n}\n\ninterface MatchCombatSettings {\n MaxRounds?: number; // default 50; timeout winner = higher HP, tie = draw\n BlockDamageMultiplier?: number; // damage fraction on a block (zones match); default 0.01 (1%)\n MinHitDamage?: number; // floor for a hit after armor; default 1\n DefaultCritMultiplier?: number; // used when CritMultiplier resolves to <= 0; default 1.5\n MaxCritChance?: number; // clamp; default 0.6\n MaxDodgeChance?: number; // clamp; default 0.4\n ArmorMode?: \"Flat\" | \"PercentReduction\"; // how armor reduces damage; default 'Flat'\n MaxArmorReduction?: number; // clamp in PercentReduction mode only (lower bound 0); default 0.9\n}\n\ninterface MatchStatFormula {\n Health?: FormulaSpec;\n Damage?: FormulaSpec;\n Armor?: FormulaSpec;\n AttackSpeed?: FormulaSpec;\n CritChance?: FormulaSpec;\n CritDamage?: FormulaSpec;\n Dodge?: FormulaSpec;\n}\n\ninterface FormulaSpec {\n Terms?: FormulaTerm[]; // the value = sum of terms\n}\n\ninterface FormulaTerm {\n Coefficient?: number; // default 1; term = Coefficient * product(Factors); no Factors = the Coefficient itself\n Factors?: FormulaFactor[];\n}\n\ninterface FormulaFactor {\n Kind?: \"Constant\" | \"Variable\" | \"Curve\"; // default Constant\n Constant?: number; // Kind = Constant; empty = 1 (does not change the product)\n VariableID?: string; // Kind = Variable\n Argument?: string; // the variable's argument (a StatID, ...)\n Curve?: ScalarCurveSpec; // Kind = Curve, evaluated at the context's step\n OnePlus?: boolean; // when true, factor contributes as (1 + value) — e.g. (1 + AllMight)\n}\n```\n\n⚠ `FormulaSpec` is a **platform** primitive and knows nothing about combat. The\nvocabulary of `VariableID` belongs to the MODULE; for instant battle it is\n`Stat`, `RankMultiplier`, `AllMight`, `GearFlat`, `GearPercent`, with `Argument`\ncarrying the `StatID` (an empty `Argument` on `Stat` means \"this role's own mapped\nstat\"). This replaced the old `FormulaSource` enum, which hard-coded those five\ncombat concepts inside the primitive.\n\n⚠ **An unknown `VariableID` means \"not computed\", not `0`.** A typo in the dashboard\ntherefore surfaces as \"my formula did not apply\" — visible and safe — rather than as a\nfighter silently walking into battle with 1 HP.\n\nA factor may carry a whole `ScalarCurveSpec` (`Kind: \"Curve\"`), but a curve can never\ncontain an expression. That is what makes the two layers acyclic by construction.\n\nThis is a **data-driven formula DSL**, not a fixed equation: each combat role\n(Health, Damage, Armor, …) is a sum of terms, each term a product of factors\npulled from a stat, a rank multiplier, gear flat/percent bonuses, or a plain\nconstant. `CombatStatMapping` is what ties an abstract role (\"Damage\") back to\na concrete character `StatID` so the character's `StatLevels` (see\n`character-system` skill) feed into it — the same `StatID`s also key\nequipment flat/percent bonuses, so a remap automatically covers gear too.\nFactors reference base per-stat values and multipliers, never another role's\n_final_ value, so there are no formula cycles.\n\n**When a role has no custom formula** (`Formula` unset for that role), the\nengine falls back to its built-in default (backend\n`PvPBattleEngine.CalculateStats`), which is useful context for previews:\n\n- `MaxHp = (Stat(Health) * RankMultiplier + GearFlat(Health)) * (1 + AllMight)`\n- `Damage = (Stat(Damage) * RankMultiplier + GearFlat(Damage)) * (1 + AllMight)`\n- `Armor = Stat(Armor) * RankMultiplier + GearFlat(Armor)` (no AllMight), then clamped to\n `[0, MaxArmorReduction]` when `ArmorMode` is `PercentReduction` (never clamped in `Flat`)\n- `AttackSpeed = (Stat(AttackSpeed) + GearFlat(AttackSpeed)) * (1 + GearPercent(AttackSpeed))` (no rank/AllMight)\n- `CritChance = Stat(CritChance) + GearFlat(CritChance) + GearPercent(CritChance)`, then clamped to `MaxCritChance`\n- `CritDamage = Stat(CritDamage) + GearFlat(CritDamage) + GearPercent(CritDamage)`, or `DefaultCritMultiplier` if ≤ 0\n- `Dodge = Stat(Dodge) + GearFlat(Dodge) + GearPercent(Dodge)`, then clamped to `MaxDodgeChance`\n\nWhere `Stat(role)` is the character's Layer-1 stat value (base + per-level\nscaling + character-rank scaling — see `character-system`\n`references/data-model.md`), `RankMultiplier` is the character's current\nrank's `RankStatCurve` value, `AllMight` is the raw (un-offset) AllMight\ncontribution from level + gear, and `GearFlat`/`GearPercent` are the\nequipped-item bonuses for that `StatID` (scaled by the item instance's\nupgrade level). **This is config for building previews/tooltips, not\nsomething to execute client-side to predict a battle outcome** — the server\nevaluates it; treat any client-side evaluation as an estimate only.\n\n---\n\n## Entry & creation settings\n\n```ts\ninterface MatchEntrySettings {\n AllowVirtualCurrency?: boolean; // default true; any title VC, no amount bound. Ignored if Allowed is non-empty\n AllowItems?: boolean; // default false; any stackable catalog item. Unstackable items are always rejected regardless\n AllowEventTokens?: boolean; // default false\n Allowed?: EntryResourceRule[]; // non-empty = authoritative whitelist; type flags above are then ignored\n MaxPositions?: number; // cap on distinct entry positions (Entries + EventTokens combined); default 10; 0 = unlimited\n}\n\ninterface EntryResourceRule {\n Kind?: \"VirtualCurrency\" | \"Item\" | \"EventToken\";\n CurrencyID?: string; // when Kind === \"VirtualCurrency\"\n CatalogID?: string; // when Kind === \"Item\"\n ItemID?: string; // when Kind === \"Item\"\n TokenType?: EventTokenType; // when Kind === \"EventToken\" (\"TimedEvent\"|\"Quest\"|\"Leaderboard\"|\"CoopEvent\"|\"Season\")\n EntityID?: string; // event/entity scoping for EventToken rules; empty = any entity of that TokenType\n MinAmount?: number; // 0 = no lower bound\n MaxAmount?: number; // 0 = no upper bound\n}\n\ninterface MatchCreationSettings {\n PriceOptions?: Record<string, PriceOption>; // ways to pay the flat fee to open a match, separate from Entry; only .Standard is honored (no premium discount), and the fee is never paid in a store (P2P + refundable)\n RefundCostOnCancel?: boolean; // default false — creation fee is sunk on cancel unless true\n MaxOpenMatches?: number; // cap on this player's simultaneous Open matches; 0 = no limit\n Limits?: LimitSpec; // only CooldownSeconds and DailyCap are enforced here; other LimitSpec axes are ignored\n AllowPrivateMatches?: boolean; // default true; whether TargetUserID is permitted at all\n MaxMatchesPerOpponentPerDay?: number; // anti win-trading cap vs one opponent, both directions, per UTC day; 0 = no limit\n}\n```\n\nUse `Allowed` + the `Allow*` booleans to build the entry-cost picker: only\noffer currencies/items/event tokens the rule permits, and clamp the amount\ninput to `MinAmount`/`MaxAmount` per matched rule. Unstackable items can never\nbe an entry position (backend rejects with `\"Unstackable items cannot be used\nas an entry.\"` regardless of policy) — refunding/awarding would have to\nrecreate the item instance and lose its upgrade level. Duplicate positions\n(same currency, or same catalog+item, or same event-token address) submitted\nin one `Entry` are merged server-side before validation, so you don't need to\ndedupe client-side.\n\n`Cost` (creation fee) is distinct from the `Entry` you pass to `createMatch`\n— both can be charged on creation (merged into one `Consume.Standard` charge),\nand `RefundCostOnCancel` (on `MatchCreationSettings`) /\n`RefundCreationCostOnCancel` (the matching flag snapshotted onto `PvPMatch` at\ncreation time) control only the creation fee on cancel; the entry cost itself\nis always refunded on a successful cancel. The creation fee is **always**\nsunk once a match is actually played (win, loss, or draw), regardless of the\nrefund flag. Don't assume what was refunded — read it off\n`CancelMatchResponse.Resources`, which reflects what the server actually\nreturned.\n\n`MaxMatchesPerOpponentPerDay` is checked twice: a courtesy pre-check on\n`createMatch` when targeting a specific opponent, and the authoritative check\non `instantBattle` (both directions of the pair, UTC calendar day, counting\n`Completed` matches) — a private challenge can still be rejected at battle\ntime even if it passed at creation time if the pair played other matches in\nbetween.\n\n---\n\n## Net reward / burn formula\n\nOn a decisive `instantBattle` (not a draw), the winner's `NetReward` doubles\neach of the loser's-and-winner's-combined entry positions and burns a share\nof virtual-currency positions only (backend `MatchHelpers.BuildNetRewardBundle`\n/ `CalculateNetReward`):\n\n- For each **VirtualCurrency** entry of amount `a`: `doubled = a * 2`,\n `burn = floor(doubled * BurnRate)`, reward amount = `doubled - burn`.\n `BurnRate` is clamped to `[0, 1]` and defaults to `0.05` (5%) when the\n rule's `Economy` is unset.\n- For each **Item** or **EventToken** entry: reward amount = `amount * 2`\n exactly — no burn (items are indivisible; burning progress-style event\n tokens would be meaningless).\n\nExample: entry of 100 coins, default 5% burn → doubled = 200, burn =\n`floor(200 * 0.05) = 10`, `NetReward` = 190 coins.\n\nSettlement by outcome (backend `MatchHelpers.BuildInstantBattleDualOps`):\n\n- **Creator wins**: joiner (loser) has `Consume.Standard = Entry` (their entry\n leaves their balance and joins the pool); creator (winner) has\n `Grant.Standard = NetReward` (their own entry was already committed at\n `createMatch`, so only the reward is granted now).\n- **Joiner wins**: creator (loser) gets an **empty** operation (their entry\n was already spent at `createMatch`, nothing more to take); joiner (winner)\n has both `Consume.Standard = Entry` (their entry is taken now, at battle\n time) **and** `Grant.Standard = NetReward` in the same operation.\n- **Draw**: no dual-party op at all. The creator is refunded their `Entry`\n via a single-party `Grant` (`Resources`, not `ResourcesDual`); the joiner\n never paid anything, so there's nothing to refund on their side. The\n creation fee is not refunded on a draw (it's sunk once played, per above).\n\nThis is why `InstantBattleResponse` carries **either** `Resources` (draw) **or**\n`ResourcesDual` (decisive) — never both — and why the SDK's cache-application\nlogic branches on which one is present (see `MatchService.instantBattle` in\nSKILL.md's Gotchas).\n\n---\n\n## Battle strategy resolution\n\nBoth `createMatch` (for the creator's strategy) and `instantBattle` (for\nwhichever side's profile is being built) resolve the strategy to use with the\nsame precedence (backend `Match.ResolveStrategyOrRandom`):\n\n1. The `battleStrategy` passed in that specific request, if non-empty.\n2. Otherwise the player's saved `PvPBattleStrategy` (from `saveStrategy`), if\n non-empty.\n3. Otherwise a **freshly randomized** 3-step strategy (random\n `AttackTarget`/`DefenseTarget` per step, generated server-side per battle\n — not persisted).\n\nAny strategy longer than 10 steps is truncated to the first 10 wherever it's\naccepted (`createMatch`, `updateMatch`, `saveStrategy`).\n\n---\n\n## Request shape\n\nEvery method builds a `MatchRequest` (extends the SDK's `BaseRequest`)\ninternally — useful context for reading error messages, not something you\nconstruct by hand:\n\n```ts\ninterface MatchRequest extends BaseRequest {\n MatchID?: string;\n TargetUserID?: string;\n Entry?: ResourceBundle;\n BattleStrategy?: BattleStepConfig[];\n CharacterID?: string;\n RuleID?: string;\n ClearTargetUser?: boolean; // UpdateMatch only; wins over TargetUserID\n Page?: number;\n PageSize?: number;\n Statuses?: string[]; // GetMyMatches filter\n OnlyPublic?: boolean; // GetAvailableMatches filter\n}\n```\n\n`createMatch` and `instantBattle` both set `RelatedEntityID` to a fresh\n`pvp_create_*`/`pvp_battle_*` UUID-suffixed string for backend idempotency/\ncorrelation — informational, not something you need to read or set yourself.\n"
|
|
8
|
+
"content": "# Match data model — reference\n\nFull shape of the config (`MatchDefinitions`), the match/battle state, and the\nrequest/response types. All of these are **strictly typed in the SDK** —\n`MatchDefinitions` and every nested block (`InstantBattleRule`,\n`MatchEntrySettings`, `MatchStatFormula`, …) are exported from\n`@idosgames/core`, so `getDefinitions()` and\n`getSection<MatchDefinitions>(\"Match\")` give you concrete types, not\n`unknown`. The schemas keep `.passthrough()`, so a field the backend adds\nlater still round-trips. Field names are PascalCase (straight from the\nbackend JSON).\n\nTerminology: the backend consistently calls the cost to participate **Entry**\nand the winner's payout **NetReward** — there is no \"stake\"/\"wager\" anywhere\nin the Match model. Use that vocabulary in any UI copy you generate.\n\n## Contents\n\n- [Player state](#player-state) — `UserMatchState`\n- [Match (offer)](#match-offer) — `PvPMatch`\n- [Battle result](#battle-result) — `BattleResult`, `BattleLogEntry`, `PlayerBattleProfile`, `FighterStats`\n- [Config: MatchDefinitions](#config-matchdefinitions) — what `getDefinitions()` returns\n- [InstantBattleRule](#instantbattlerule)\n- [Combat formulas](#combat-formulas) — `CombatStatMapping`, `MatchStatFormula`, `FormulaTerm`/`FormulaFactor`, the stat-calculation layers\n- [Entry & creation settings](#entry--creation-settings) — whitelist, anti-abuse limits\n- [Net reward / burn formula](#net-reward--burn-formula)\n- [Battle strategy resolution](#battle-strategy-resolution) — random fallback, step cap\n- [Request shape](#request-shape)\n\n---\n\n## Player state\n\nCached at `client.data.user.state?.Match` as `UserMatchState`. Populated\n**only** by `saveStrategy` in this SDK (see Gotchas in SKILL.md) — nothing\nhydrates it at login.\n\n```ts\ninterface UserMatchState {\n PvPBattleStrategy?: BattleStepConfig[];\n CreationLimits?: UserMatchCreationLimitState | null;\n}\n\ninterface UserMatchCreationLimitState {\n LastCreatedAt?: string; // ISO timestamp of the last createMatch call\n DailyCreations?: number; // counter toward Limits in MatchCreationSettings\n DailyResetUtc?: string; // next UTC midnight reset\n}\n\ninterface BattleStepConfig {\n AttackTarget: \"Head\" | \"Torso\" | \"Legs\";\n DefenseTarget: \"Head\" | \"Torso\" | \"Legs\";\n}\n```\n\n`CreationLimits` mirrors the cooldown/daily-cap counters the backend keeps\nserver-side (`Match.CreationLimits` on the player document, written by\n`MatchHelpers.BuildCreationLimitPatches` inside the `createMatch` transaction)\nfor a rule's `Creation.Limits` (a `LimitSpec`). It's exposed on the wire type\nfor a client to eventually show \"next match available in…\" UI, but nothing in\n`MatchService` currently reads it back into this cache slot — treat it as\ninformational/future until a response actually populates it for you.\n\n---\n\n## Match (offer)\n\n`PvPMatch` — one open/resolved match, returned inside `MatchesPageResponse`\nand `UpdateMatchResponse`.\n\n```ts\ninterface PvPMatch {\n MatchID: string;\n TitleID?: string;\n RuleID?: string;\n CreatedAt?: string;\n CreatorID?: string;\n CreatorCharacterID?: string;\n CreatorStrategy?: BattleStepConfig[]; // stripped from GetAvailableMatches listings\n TargetUserID?: string; // set = private/targeted challenge; absent = public\n Entry?: ResourceBundle; // the creator's entry cost\n CreationCostPaid?: ResourceBundle; // separate from Entry — the fee to open the match\n RefundCreationCostOnCancel?: boolean;\n JoinedByUserID?: string;\n JoinedByCharacterID?: string;\n JoinedAt?: string;\n Status?: \"Open\" | \"InProgress\" | \"Cancelled\" | \"Completed\" | \"Expired\";\n ExpiresAt?: string; // ISO UTC; absent = never expires. Past it the offer is dead even while still \"Open\"\n WinnerUserID?: string; // absent/null on a draw\n CompletedAt?: string;\n IsRewardDistributed?: boolean;\n RewardDistributedAt?: string;\n}\n```\n\n`Entry` (the cost to participate) and `CreationCostPaid` (the fee to list the\nmatch) are tracked separately — cancelling refunds the entry cost always, and\nthe creation fee only when `RefundCreationCostOnCancel` is true.\n\n`Status` never actually passes through `\"InProgress\"` in this backend: a join\nresolves the battle synchronously in the same call, so a match goes directly\n`Open → Completed` (backend: `MatchDatabase.TryFinalizeOpenMatchInSessionAsync`\nsets `Completed` straight from an `Open` filter). `\"InProgress\"` exists in the\nenum for forward-compat / other match modes, not for instant-battle.\n\n`GetAvailableMatches` listings project out `CreatorStrategy` (backend:\n`MatchDatabase.ReadAvailableMatchesPagedAsync` excludes it) — you only see a\nmatch's strategy from `getMyMatches` or after you've fetched the match some\nother way; it isn't needed to join, since your own strategy is what you send\nto `instantBattle`.\n\n---\n\n## Battle result\n\nReturned inside `InstantBattleResponse.Battle`.\n\n```ts\ninterface BattleResult {\n WinnerUserID?: string; // absent on a draw\n LoserUserID?: string; // absent on a draw\n Entry?: ResourceBundle; // one side's entry cost that was in play\n NetReward?: ResourceBundle; // winner's payout after burn; null on a draw\n BattleLog?: BattleLogEntry[]; // full round-by-round hit list\n IsDraw?: boolean;\n P1BattleProfile?: PlayerBattleProfile; // the match creator\n P2BattleProfile?: PlayerBattleProfile; // the joiner (the caller of instantBattle)\n}\n\ninterface BattleLogEntry {\n RoundIndex?: number; // 1-based\n AttackerID?: string;\n DefenderID?: string;\n AttackZone?: \"Head\" | \"Torso\" | \"Legs\";\n DefenseZone?: \"Head\" | \"Torso\" | \"Legs\";\n HitType?: \"Hit\" | \"Critical\" | \"Block\" | \"Dodge\";\n DamageDealt?: number; // 0 on Dodge; reduced (BlockDamageMultiplier) on Block\n DefenderHpRemaining?: number; // floored at 0\n}\n\ninterface PlayerBattleProfile {\n UserID?: string;\n SelectedCharacterID?: string;\n SelectedCharacter?: CharacterModel; // see character-system skill\n BattleStrategy?: BattleStepConfig[]; // the strategy actually used (inline, saved, or random)\n UnstackableItems?: Record<string, UnstackableItemInstanceState>; // always null on the wire in practice\n Stats?: FighterStats; // final computed combat stats used for this fight\n}\n\ninterface FighterStats {\n MaxHp?: number; // starting HP, for a results-screen HP bar\n CurrentHp?: number; // HP at the end of the fight\n Damage?: number;\n AttackSpeed?: number; // higher acts first each round; a tie coin-flips\n CritChance?: number; // 0..MaxCritChance\n CritMultiplier?: number;\n Armor?: number; // meaning depends on ArmorMode: damage units (Flat) or a 0..MaxArmorReduction fraction (PercentReduction)\n DodgeChance?: number; // 0..MaxDodgeChance\n}\n```\n\nEach round, both fighters act in `AttackSpeed` order (ties broken by a random\ncoin flip — backend `PvPBattleEngine.SimulateBattle`); if the first attacker's\nhit drops the defender to 0 HP, the defender does not get to act that round.\n`AttackZone`/`DefenseZone` per log entry come from each side's\n`BattleStrategy`, indexed `(RoundIndex - 1) % strategy.length` — a\nstrategy shorter than the battle simply repeats from the top.\n\nPer-hit resolution order (backend `PvPBattleEngine.PerformAttack`):\n\n1. Roll crit: `isCrit = random() < attacker.CritChance`.\n2. `rawDamage = isCrit ? attacker.Damage * attacker.CritMultiplier : attacker.Damage`.\n3. Apply armor per `ArmorMode`:\n - `Flat` (default): `potentialDamage = max(MinHitDamage, rawDamage - defender.Armor)`.\n - `PercentReduction`: `potentialDamage = max(MinHitDamage, rawDamage * (1 - defender.Armor))`,\n with `Armor` already clamped to `[0, MaxArmorReduction]` in step 7 of `CalculateStats`.\n The `MinHitDamage` floor applies in both modes, so armor can never heal the defender.\n4. If `AttackZone === DefenseZone` → **Block**: `DamageDealt = round(potentialDamage * BlockDamageMultiplier, 2)`.\n5. Else if `random() < defender.DodgeChance` → **Dodge**: `DamageDealt = 0`.\n6. Else → **Hit** (or **Critical** if step 1 rolled true): `DamageDealt = potentialDamage`.\n\nThe battle ends when a fighter's HP hits 0, or after `MaxRounds` (default 50)\n— on timeout, higher remaining HP wins; exactly equal HP is a draw.\n\n`PlayerBattleProfile.UnstackableItems` is documented as an **input-only**\nlevel-scaling snapshot the engine used internally — the backend\n(`PvPBattleEngine.TrimProfile`) always strips it before the response reaches\nthe client, so expect it to read as `null`/absent in practice. Don't build UI\nthat depends on it being populated.\n\n`FighterStats` is the resolved combat stats each fighter fought with — read\nthese for a \"stat comparison\" results screen, but they are a snapshot of that\none fight, not a live/cached character stat.\n\n---\n\n## Config: MatchDefinitions\n\nReturned by `getDefinitions()`; cached via\n`client.data.config.getSection<MatchDefinitions>(\"Match\")`.\n\n```ts\ninterface MatchDefinitions {\n InstantBattle?: InstantBattleDefinitions;\n}\n\ninterface InstantBattleDefinitions {\n Rules?: Record<string, InstantBattleRule>; // key = RuleID\n Defaults?: InstantBattleSettings; // title-wide combat fallback\n EntryDefaults?: MatchEntrySettings; // title-wide entry-whitelist fallback\n CreationDefaults?: MatchCreationSettings; // title-wide creation cost/limits fallback\n}\n```\n\nCurrently `MatchDefinitions` has a single mode container, `InstantBattle` —\nthere's no other battle mode in the model today. If the title hasn't\nconfigured `Match` at all, `getDefinitions()` still returns a synthesized\nconfig with one rule, `\"InstantBattle1v1\"`, built from engine defaults\n(backend: `Match.GetDefinitions` / `MatchConfigResolver.BuiltInInstantBattle1v1`)\n— so there is always at least one valid `RuleID` to pass.\n\nResolution order for every block is **rule's own → title `Defaults` (or\n`EntryDefaults`/`CreationDefaults`) → engine built-in default** — the same\ninline-over-preset pattern the character module uses. `StatMapping` resolves\nper-field (each role can come from a different layer); `Combat`, `Entry`,\n`Creation` resolve as whole blocks (a rule that sets `Entry` gets none of the\ntitle's `EntryDefaults`, even for fields it left unset).\n\n---\n\n## InstantBattleRule\n\nOne entry in `InstantBattleDefinitions.Rules`, keyed by `RuleID` (the string\nyou pass as `ruleID` to `createMatch`/`updateMatch`/`instantBattle`).\n\n```ts\ninterface InstantBattleRule {\n RuleID?: string;\n DisplayName?: string;\n Description?: string;\n Economy?: MatchEconomySettings;\n Entry?: MatchEntrySettings;\n Creation?: MatchCreationSettings;\n Settings?: InstantBattleSettings;\n}\n\ninterface MatchEconomySettings {\n BurnRate?: number; // fraction of the *reward* burned when paid to the winner; default 0.05 (5%)\n}\n```\n\n---\n\n## Combat formulas\n\n`InstantBattleSettings` (a rule's `Settings`, or the title-wide `Defaults`)\nconfigures how a fighter's `FighterStats` are derived for a battle.\n\n```ts\ninterface InstantBattleSettings {\n StatMapping?: CombatStatMapping;\n Combat?: MatchCombatSettings;\n Formula?: MatchStatFormula;\n}\n\ninterface CombatStatMapping {\n HealthStatID?: string; // which character StatID feeds MaxHp. Default: \"Health\"\n DamageStatID?: string; // Default: \"Damage\"\n ArmorStatID?: string; // Default: \"Armor\"\n AttackSpeedStatID?: string; // Default: \"AttackSpeed\"\n CritChanceStatID?: string; // Default: \"CritChance\"\n CritDamageStatID?: string; // Default: \"CritDamage\"\n DodgeStatID?: string; // Default: \"Speed\"\n AllMightStatID?: string; // overall percent multiplier stat. Default: \"AllMight\"\n}\n\ninterface MatchCombatSettings {\n MaxRounds?: number; // default 50; timeout winner = higher HP, tie = draw\n BlockDamageMultiplier?: number; // damage fraction on a block (zones match); default 0.01 (1%)\n MinHitDamage?: number; // floor for a hit after armor; default 1\n DefaultCritMultiplier?: number; // used when CritMultiplier resolves to <= 0; default 1.5\n MaxCritChance?: number; // clamp; default 0.6\n MaxDodgeChance?: number; // clamp; default 0.4\n ArmorMode?: \"Flat\" | \"PercentReduction\"; // how armor reduces damage; default 'Flat'\n MaxArmorReduction?: number; // clamp in PercentReduction mode only (lower bound 0); default 0.9\n}\n\ninterface MatchStatFormula {\n Health?: FormulaSpec;\n Damage?: FormulaSpec;\n Armor?: FormulaSpec;\n AttackSpeed?: FormulaSpec;\n CritChance?: FormulaSpec;\n CritDamage?: FormulaSpec;\n Dodge?: FormulaSpec;\n}\n\ninterface FormulaSpec {\n Terms?: FormulaTerm[]; // the value = sum of terms\n}\n\ninterface FormulaTerm {\n Coefficient?: number; // default 1; term = Coefficient * product(Factors); no Factors = the Coefficient itself\n Factors?: FormulaFactor[];\n}\n\ninterface FormulaFactor {\n Kind?: \"Constant\" | \"Variable\" | \"Curve\"; // default Constant\n Constant?: number; // Kind = Constant; empty = 1 (does not change the product)\n VariableID?: string; // Kind = Variable\n Argument?: string; // the variable's argument (a StatID, ...)\n Curve?: ScalarCurveSpec; // Kind = Curve, evaluated at the context's step\n OnePlus?: boolean; // when true, factor contributes as (1 + value) — e.g. (1 + AllMight)\n}\n```\n\n⚠ `FormulaSpec` is a **platform** primitive and knows nothing about combat. The\nvocabulary of `VariableID` belongs to the MODULE; for instant battle it is\n`Stat`, `RankMultiplier`, `AllMight`, `GearFlat`, `GearPercent`, with `Argument`\ncarrying the `StatID` (an empty `Argument` on `Stat` means \"this role's own mapped\nstat\"). This replaced the old `FormulaSource` enum, which hard-coded those five\ncombat concepts inside the primitive.\n\n⚠ **An unknown `VariableID` means \"not computed\", not `0`.** A typo in the dashboard\ntherefore surfaces as \"my formula did not apply\" — visible and safe — rather than as a\nfighter silently walking into battle with 1 HP.\n\nA factor may carry a whole `ScalarCurveSpec` (`Kind: \"Curve\"`), but a curve can never\ncontain an expression. That is what makes the two layers acyclic by construction.\n\nThis is a **data-driven formula DSL**, not a fixed equation: each combat role\n(Health, Damage, Armor, …) is a sum of terms, each term a product of factors\npulled from a stat, a rank multiplier, gear flat/percent bonuses, or a plain\nconstant. `CombatStatMapping` is what ties an abstract role (\"Damage\") back to\na concrete character `StatID` so the character's `StatLevels` (see\n`character-system` skill) feed into it — the same `StatID`s also key\nequipment flat/percent bonuses, so a remap automatically covers gear too.\nFactors reference base per-stat values and multipliers, never another role's\n_final_ value, so there are no formula cycles.\n\n**When a role has no custom formula** (`Formula` unset for that role), the\nengine falls back to its built-in default (backend\n`PvPBattleEngine.CalculateStats`), which is useful context for previews:\n\n- `MaxHp = (Stat(Health) * RankMultiplier + GearFlat(Health)) * (1 + AllMight)`\n- `Damage = (Stat(Damage) * RankMultiplier + GearFlat(Damage)) * (1 + AllMight)`\n- `Armor = Stat(Armor) * RankMultiplier + GearFlat(Armor)` (no AllMight), then clamped to\n `[0, MaxArmorReduction]` when `ArmorMode` is `PercentReduction` (never clamped in `Flat`)\n- `AttackSpeed = (Stat(AttackSpeed) + GearFlat(AttackSpeed)) * (1 + GearPercent(AttackSpeed))` (no rank/AllMight)\n- `CritChance = Stat(CritChance) + GearFlat(CritChance) + GearPercent(CritChance)`, then clamped to `MaxCritChance`\n- `CritDamage = Stat(CritDamage) + GearFlat(CritDamage) + GearPercent(CritDamage)`, or `DefaultCritMultiplier` if ≤ 0\n- `Dodge = Stat(Dodge) + GearFlat(Dodge) + GearPercent(Dodge)`, then clamped to `MaxDodgeChance`\n\nWhere `Stat(role)` is the character's Layer-1 stat value (base + per-level\nscaling + character-rank scaling — see `character-system`\n`references/data-model.md`), `RankMultiplier` is the character's current\nrank's `RankStatCurve` value, `AllMight` is the raw (un-offset) AllMight\ncontribution from level + gear, and `GearFlat`/`GearPercent` are the\nequipped-item bonuses for that `StatID` (scaled by the item instance's\nupgrade level). **This is config for building previews/tooltips, not\nsomething to execute client-side to predict a battle outcome** — the server\nevaluates it; treat any client-side evaluation as an estimate only.\n\n---\n\n## Entry & creation settings\n\n```ts\ninterface MatchEntrySettings {\n AllowVirtualCurrency?: boolean; // default true; any title VC, no amount bound. Ignored if Allowed is non-empty\n AllowItems?: boolean; // default false; any stackable catalog item. Unstackable items are always rejected regardless\n AllowEventTokens?: boolean; // default false\n Allowed?: EntryResourceRule[]; // non-empty = authoritative whitelist; type flags above are then ignored\n MaxPositions?: number; // cap on distinct entry positions (Entries + EventTokens combined); default 10; 0 = unlimited\n}\n\ninterface EntryResourceRule {\n Kind?: \"VirtualCurrency\" | \"Item\" | \"EventToken\";\n CurrencyID?: string; // when Kind === \"VirtualCurrency\"\n CatalogID?: string; // when Kind === \"Item\"\n ItemID?: string; // when Kind === \"Item\"\n TokenType?: EventTokenType; // when Kind === \"EventToken\" (\"TimedEvent\"|\"Quest\"|\"Leaderboard\"|\"CoopEvent\"|\"Season\")\n EntityID?: string; // event/entity scoping for EventToken rules; empty = any entity of that TokenType\n MinAmount?: number; // 0 = no lower bound\n MaxAmount?: number; // 0 = no upper bound\n}\n\ninterface MatchCreationSettings {\n PriceOptions?: Record<string, PriceOption>; // ways to pay the flat fee to open a match, separate from Entry; only .Standard is honored (no premium discount), and the fee is never paid in a store (P2P + refundable)\n RefundCostOnCancel?: boolean; // default false — creation fee is sunk on cancel unless true\n MaxOpenMatches?: number; // cap on this player's simultaneous Open matches; 0 = no limit\n OfferLifetimeSeconds?: number; // offer lifetime since creation; on expiry the engine refunds the entry (status \"Expired\"); 0 = never\n Limits?: LimitSpec; // only CooldownSeconds and DailyCap are enforced here; other LimitSpec axes are ignored\n AllowPrivateMatches?: boolean; // default true; whether TargetUserID is permitted at all\n MaxMatchesPerOpponentPerDay?: number; // anti win-trading cap vs one opponent, both directions, per UTC day; 0 = no limit\n}\n```\n\nUse `Allowed` + the `Allow*` booleans to build the entry-cost picker: only\noffer currencies/items/event tokens the rule permits, and clamp the amount\ninput to `MinAmount`/`MaxAmount` per matched rule. Unstackable items can never\nbe an entry position (backend rejects with `\"Unstackable items cannot be used\nas an entry.\"` regardless of policy) — refunding/awarding would have to\nrecreate the item instance and lose its upgrade level. Duplicate positions\n(same currency, or same catalog+item, or same event-token address) submitted\nin one `Entry` are merged server-side before validation, so you don't need to\ndedupe client-side.\n\n`Cost` (creation fee) is distinct from the `Entry` you pass to `createMatch`\n— both can be charged on creation (merged into one `Consume.Standard` charge),\nand `RefundCostOnCancel` (on `MatchCreationSettings`) /\n`RefundCreationCostOnCancel` (the matching flag snapshotted onto `PvPMatch` at\ncreation time) control only the creation fee on cancel; the entry cost itself\nis always refunded on a successful cancel. The creation fee is **always**\nsunk once a match is actually played (win, loss, or draw), regardless of the\nrefund flag. Don't assume what was refunded — read it off\n`CancelMatchResponse.Resources`, which reflects what the server actually\nreturned.\n\n`MaxMatchesPerOpponentPerDay` is checked twice: a courtesy pre-check on\n`createMatch` when targeting a specific opponent, and the authoritative check\non `instantBattle` (both directions of the pair, UTC calendar day, counting\n`Completed` matches) — a private challenge can still be rejected at battle\ntime even if it passed at creation time if the pair played other matches in\nbetween.\n\n---\n\n## Net reward / burn formula\n\nOn a decisive `instantBattle` (not a draw), the winner's `NetReward` doubles\neach of the loser's-and-winner's-combined entry positions and burns a share\nof virtual-currency positions only (backend `MatchHelpers.BuildNetRewardBundle`\n/ `CalculateNetReward`):\n\n- For each **VirtualCurrency** entry of amount `a`: `doubled = a * 2`,\n `burn = floor(doubled * BurnRate)`, reward amount = `doubled - burn`.\n `BurnRate` is clamped to `[0, 1]` and defaults to `0.05` (5%) when the\n rule's `Economy` is unset.\n- For each **Item** or **EventToken** entry: reward amount = `amount * 2`\n exactly — no burn (items are indivisible; burning progress-style event\n tokens would be meaningless).\n\nExample: entry of 100 coins, default 5% burn → doubled = 200, burn =\n`floor(200 * 0.05) = 10`, `NetReward` = 190 coins.\n\nSettlement by outcome (backend `MatchHelpers.BuildInstantBattleDualOps`):\n\n- **Creator wins**: joiner (loser) has `Consume.Standard = Entry` (their entry\n leaves their balance and joins the pool); creator (winner) has\n `Grant.Standard = NetReward` (their own entry was already committed at\n `createMatch`, so only the reward is granted now).\n- **Joiner wins**: creator (loser) gets an **empty** operation (their entry\n was already spent at `createMatch`, nothing more to take); joiner (winner)\n has both `Consume.Standard = Entry` (their entry is taken now, at battle\n time) **and** `Grant.Standard = NetReward` in the same operation.\n- **Draw**: no dual-party op at all. The creator is refunded their `Entry`\n via a single-party `Grant` (`Resources`, not `ResourcesDual`); the joiner\n never paid anything, so there's nothing to refund on their side. The\n creation fee is not refunded on a draw (it's sunk once played, per above).\n\nThis is why `InstantBattleResponse` carries **either** `Resources` (draw) **or**\n`ResourcesDual` (decisive) — never both — and why the SDK's cache-application\nlogic branches on which one is present (see `MatchService.instantBattle` in\nSKILL.md's Gotchas).\n\n---\n\n## Battle strategy resolution\n\nBoth `createMatch` (for the creator's strategy) and `instantBattle` (for\nwhichever side's profile is being built) resolve the strategy to use with the\nsame precedence (backend `Match.ResolveStrategyOrRandom`):\n\n1. The `battleStrategy` passed in that specific request, if non-empty.\n2. Otherwise the player's saved `PvPBattleStrategy` (from `saveStrategy`), if\n non-empty.\n3. Otherwise a **freshly randomized** 3-step strategy (random\n `AttackTarget`/`DefenseTarget` per step, generated server-side per battle\n — not persisted).\n\nAny strategy longer than 10 steps is truncated to the first 10 wherever it's\naccepted (`createMatch`, `updateMatch`, `saveStrategy`).\n\n---\n\n## Request shape\n\nEvery method builds a `MatchRequest` (extends the SDK's `BaseRequest`)\ninternally — useful context for reading error messages, not something you\nconstruct by hand:\n\n```ts\ninterface MatchRequest extends BaseRequest {\n MatchID?: string;\n TargetUserID?: string;\n Entry?: ResourceBundle;\n BattleStrategy?: BattleStepConfig[];\n CharacterID?: string;\n RuleID?: string;\n ClearTargetUser?: boolean; // UpdateMatch only; wins over TargetUserID\n Page?: number;\n PageSize?: number;\n Statuses?: string[]; // GetMyMatches filter\n OnlyPublic?: boolean; // GetAvailableMatches filter\n}\n```\n\n`createMatch` and `instantBattle` both set `RelatedEntityID` to a fresh\n`pvp_create_*`/`pvp_battle_*` UUID-suffixed string for backend idempotency/\ncorrelation — informational, not something you need to read or set yourself.\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "multiplayer-realtime",
|
|
3
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",
|
|
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. A title may also\n set a hard lifetime (`MaxRoomLifetimeSeconds`, counted from creation, NOT\n renewed by activity): when it passes, members receive `RoomClosed` with\n `Kind: \"expired\"` mid-game — save progress and offer \"new room\".\n- **`sendChat` only works under `ChatMode: ServerRelay`.** Under `P2P` chat\n mode, send chat over your own WebRTC DataChannel instead — calling\n `sendChat` in `P2P` mode fails with `\"Chat is peer-to-peer in this room;\nsend it over the DataChannel.\"` (check the room snapshot's `ChatMode`\n before wiring a chat send button to it).\n- **`closeRoom` works for the owner or the current host, not just the\n original creator.** After a host migration the new host can also close the\n room; anyone else gets `\"Only the room owner or host can close the room.\"`.\n Regular non-host, non-owner members only have `leaveRoom` (which itself may\n close the room if it empties out).\n- **Private rooms 404 instead of 403.** `getRoom`/`poll`/etc. on a private\n room you're not a member of returns the same `\"Room not found.\"` as a\n genuinely unknown id — the backend doesn't leak room existence to\n non-members.\n- **No automatic eviction — except via `claimHost`.** `RoomMemberView.LastSeenMs`\n is a last-heartbeat timestamp for clients to compare against\n `MultiplayerDefinitions.MemberTimeoutMs` themselves (e.g. to gray out a\n presumed-disconnected member). The server never evicts a stale member on\n its own; the one exception is `claimHost`, which removes a HOST that has\n been silent longer than `MemberTimeoutMs` and hands the role to the caller.\n- **Poll cadence is uncapped by the SDK — you own the rate.** There's no\n built-in backoff or interval; hitting the same endpoint faster than the\n transport's throttle window returns `reason: \"throttled\"` rather than\n duplicating work. `HeartbeatIntervalMs` is the title's _recommended_\n interval, not an enforced one. Design your loop's interval deliberately\n rather than polling in a tight `while(true)`.\n- **`Payload`/`signalKind` are opaque strings.** The server does not validate\n WebRTC SDP/ICE structure — malformed payloads only fail on the _receiving_\n client when it tries to parse/apply them, not at the relay. It does,\n however, enforce a byte-size ceiling (`MaxSignalPayloadBytes`) before\n relaying at all.\n- **Guard against double-submit** on room actions (create/join/close): each\n call is a distinct request, so two separate calls are two real operations\n server-side (e.g. two `createRoom` calls can both succeed as two different\n rooms if the first response is slow). Disable the control while a call is\n in flight.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — full shape of\n`RoomSnapshotResponse`, `RoomMemberView`, `RealtimeEnvelope`/`EnvelopeType`,\n`MultiplayerDefinitions`, and the request/action surface, plus the room\nlifecycle state machine (Open → Closed, host migration, TTL expiry). Read it\nwhen building richer room/lobby UI or wiring up the poll loop's envelope\nhandling in detail.\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
8
|
-
"content": "# Multiplayer data model — reference\n\nFull shape of the config (`MultiplayerDefinitions`), the room/member view\nmodels, the realtime envelope union, and the request/action surface. All of\nthese are **strictly typed in the SDK** and exported from `@idosgames/core`.\nThe zod schemas keep `.passthrough()`, so a field the backend adds later still\nround-trips as `unknown` extra data rather than being stripped. Field names\nare PascalCase (straight from the backend JSON).\n\nThere is no player-state shape to document here — Multiplayer has none. Every\nmodel below is either title-wide config or an ephemeral, per-room view.\n\n## Contents\n\n- [Config: MultiplayerDefinitions](#config-multiplayerdefinitions) — what rides in on the title's public config\n- [Room lifecycle state machine](#room-lifecycle-state-machine)\n- [RoomSnapshotResponse](#roomsnapshotresponse)\n- [RoomMemberView](#roommemberview)\n- [RoomListItem](#roomlistitem--roomspageresponse)\n- [RealtimeEnvelope + EnvelopeType](#realtimeenvelope--envelopetype)\n- [LeaveRoomResponse / PublishResponse / PollResponse](#other-responses)\n- [Request + actions](#request--actions)\n\n---\n\n## Config: MultiplayerDefinitions\n\nBackend: `MultiplayerDefinitions.cs`, stored at\n`TitlePublicConfigurationModel.Multiplayer`. Public-only data — no secrets.\nThere is no dedicated `getMultiplayerDefinitions()` fetch; this arrives with\nthe full title config (`client.title.getTitlePublicConfiguration()`, or\nalready present after the login bootstrap). See\n[Reading limits from config](../SKILL.md#reading-limits-from-config) in the\nmain skill.\n\n```ts\ninterface MultiplayerDefinitions {\n SystemState?: { Enabled?: boolean }; // title-wide feature switch\n DefaultTopology?: MultiplayerTopology; // \"Mesh\" | \"Host\"; default \"Mesh\"\n AllowCreatorTopologyOverride?: boolean; // default true\n DefaultChatMode?: MultiplayerChatMode; // \"ServerRelay\" | \"P2P\"; default \"ServerRelay\"\n AllowCreatorChatOverride?: boolean; // default true\n MaxPlayersPerRoom?: number; // hard cap; default 8\n RoomTtlSeconds?: number; // Redis key TTL, renewed on every interaction; default 3600\n HeartbeatIntervalMs?: number; // recommended client poll interval; default 1000 — NOT enforced\n MemberTimeoutMs?: number; // \"presumed offline\" threshold; default 15000. The server evicts nobody on its own — except a HOST silent this long, via claimHost\n MaxSignalPayloadBytes?: number; // default 16384\n MaxChatTextLength?: number; // characters; default 500\n RoomLogCap?: number; // how many broadcast envelopes the room-log tail keeps; default 500\n IceServers?: IceServer[]; // STUN only here (public config!); empty/absent -> public STUN fallback. TURN comes per member from the engine (Cloudflare), never from this list\n}\n\ninterface IceServer {\n Urls?: string; // e.g. \"stun:stun.l.google.com:19302\" or \"turn:turn.example.com:3478\"\n Username?: string; // TURN only\n Credential?: string; // TURN only\n}\n```\n\n`MultiplayerTopology`: `\"Mesh\"` (every player connects to every other —\nsmall rooms) | `\"Host\"` (star topology, everyone connects only to the host).\n\n`MultiplayerChatMode`: `\"ServerRelay\"` (chat broadcast through the room-log,\nworks in lobby and as a fallback) | `\"P2P\"` (server does not process chat at\nall — send it over your own DataChannel).\n\n`RoomVisibility`: `\"Public\"` (appears in `listRooms`) | `\"Private\"` (not\nlisted; join by `RoomID` + join code only).\n\n---\n\n## Room lifecycle state machine\n\nA room's `Status` (surfaced as `RoomSnapshotResponse.Status`) is a plain\nstring, driven by these transitions (backend: `RoomService.cs` +\n`RedisService.Realtime.cs`):\n\n- **created** → `\"Open\"`. `createRoom` sets it; the caller becomes both\n `OwnerUserID` and `HostUserID`.\n- **join** — allowed only while `Open` and under `MaxPlayers`; rejected with\n `\"Room is closed.\"` / `\"Room is full.\"` otherwise. A private room additionally\n requires the join code to match (SHA-256 hash comparison server-side —\n the raw code is never stored).\n- **leave** — if the leaving member was the last one, the room is deleted\n outright (`Closed: true` in the `LeaveRoomResponse`, no `RoomClosed`\n envelope is published — there's nobody left to receive it). If members\n remain and the leaver was the host, a new host is picked from the\n remaining members (`NewHostUserID`) and a `HostChanged` envelope is\n broadcast; the room stays `Open`.\n- **leave with a successor** — a leaving host may pass `successorUserID`; if\n that player is still a member they become host (otherwise the server picks\n one). `RealtimeSession` does this on `leave()` and on page close.\n- **claimHost** — a member takes the host role when the host has not polled\n for `MemberTimeoutMs`: the silent host is removed, `PlayerLeft` (Kind\n `\"timeout\"`) and `HostChanged` are broadcast. A compare-and-set in Redis\n guarantees one winner; a still-polling host → `\"The host is still active.\"`.\n- **kick / ban** (`kickMember`, owner or current host; never the host or the\n owner themselves) — `PlayerLeft` (Kind `\"kicked\"`). With `ban` the player is\n added to the room's ban list, so `joinRoom` answers `\"You were removed from\nthis room.\"`. The list survives host changes and dies with the room;\n `unbanMember` removes an entry.\n- **close** (`closeRoom`, owner or current host only) → a `RoomClosed`\n envelope is broadcast _before_ the room is marked `Closed`, so in-flight\n pollers still receive the notice; the room's keys then live out their\n remaining TTL rather than being deleted immediately (so late polls still\n resolve cleanly instead of hitting \"gone\").\n- **idle expiry** — every interaction (`poll`, `sendSignal`, `sendChat`,\n `setMemberState`, join/leave) renews the room's Redis TTL\n (`RoomTtlSeconds`). Stop interacting and the room silently disappears; a\n subsequent `poll` on it fails with `\"Room no longer exists.\"` (internally\n the `\"gone\"` code) rather than describing expiry explicitly.\n\nThere is no separate \"starting\"/\"in-progress\" server-tracked status — once\npeers have exchanged signals and opened their DataChannel, that phase is\nentirely off the server's radar. If your game needs a \"match started\" state,\nmodel it yourself via `setMemberState`'s free-form `MemberState` map or your\nown signaling payloads.\n\n---\n\n## RoomSnapshotResponse\n\nReturned by `createRoom`, `joinRoom`, and `getRoom`.\n\n```ts\ninterface RoomSnapshotResponse {\n RoomID?: string;\n Name?: string;\n HostUserID?: string; // current host; may differ from OwnerUserID after migration\n OwnerUserID?: string; // original creator; fixed for the room's lifetime\n Topology?: MultiplayerTopology;\n ChatMode?: MultiplayerChatMode;\n Visibility?: RoomVisibility;\n Status?: string; // \"Open\" | \"Closed\"\n MaxPlayers?: number;\n Members?: RoomMemberView[]; // ordered by JoinedAt ascending\n IceServers?: IceServer[]; // resolved: title STUN (or public fallback) + per-member TURN credentials when the engine has Cloudflare TURN\n Seq?: number; // starting poll cursor for this room\n BannedUserIDs?: string[]; // the ban list — only in the host's / owner's snapshot\n}\n```\n\n`JoinCode` is deliberately never returned in any response — only its SHA-256\nhash is stored server-side, checked against what `joinRoom` submits.\n\n---\n\n## RoomMemberView\n\nOne room member: static public profile fields plus mutable lobby state.\n\n```ts\ninterface RoomMemberView {\n UserID?: string;\n Username?: string;\n AvatarUrl?: string;\n Level?: number; // snapshotted from the player's public profile at join time\n Power?: number; // snapshotted from the player's public profile at join time\n Ready?: boolean; // mutated via setMemberState\n State?: Record<string, string>; // free-form lobby metadata, mutated via setMemberState\n JoinedAt?: number; // unix ms\n IsHost?: boolean; // computed on read: UserID === room's current HostUserID\n LastSeenMs?: number; // unix ms of last heartbeat (poll); compare against MemberTimeoutMs yourself\n}\n```\n\n`Level`/`Power` are a snapshot taken from the player's public profile\n(`UserPublicDataModel`) at the moment they joined — they do **not** live-update\nif the player's Character Power changes mid-room. Re-`joinRoom` (or a fresh\n`getRoom`) is the only way to refresh them; there's no push for profile\nchanges into an existing member record.\n\n`IsHost` and `LastSeenMs` are computed server-side on every read (from\n`hostUserId` in room meta and the presence Sorted Set respectively) — they\nare not stored on the member record itself.\n\n---\n\n## RoomListItem + RoomsPageResponse\n\nReturned by `listRooms`. A deliberately thin projection — no `Members`, no\njoin code, no `Seq` — for cheap listing pages.\n\n```ts\ninterface RoomListItem {\n RoomID?: string;\n Name?: string;\n HostUserID?: string;\n Topology?: MultiplayerTopology;\n ChatMode?: MultiplayerChatMode;\n MaxPlayers?: number;\n MemberCount?: number;\n CreatedAt?: number; // unix ms\n}\n\ninterface RoomsPageResponse {\n Rooms?: RoomListItem[];\n Page?: number;\n PageSize?: number; // server-echoed; clamped to <= 50 regardless of what you requested\n HasMore?: boolean;\n}\n```\n\nOnly `Public`-visibility, currently-`Open` rooms ever appear here — private\nrooms and closed rooms are excluded at the index level, not filtered\nclient-side.\n\n---\n\n## RealtimeEnvelope + EnvelopeType\n\nThe unified delivery item returned by `poll`. `Seq` is a per-room, strictly\nincreasing counter shared by both the broadcast log and every member's\ndirected inbox — that's what makes a single `lastSeq` cursor sufficient to\ndrain all of it.\n\n```ts\ntype EnvelopeType =\n | \"Signal\" // directed: SDP offer/answer or ICE candidate\n | \"Chat\" // broadcast: ChatMode=ServerRelay message\n | \"PlayerJoined\" // broadcast: someone joined\n | \"PlayerLeft\" // broadcast: someone left\n | \"HostChanged\" // broadcast: host migrated\n | \"RoomClosed\" // broadcast: room closed by owner/host\n | \"MemberState\"; // broadcast: someone's Ready/State changed\n\ninterface RealtimeEnvelope {\n Seq?: number;\n Type?: EnvelopeType;\n FromUserID?: string;\n ToUserID?: string; // present only on directed \"Signal\" envelopes\n Kind?: string; // \"Signal\": free-form (\"offer\" | \"answer\" | \"candidate\" by convention; RealtimeSession uses \"rtc\");\n // \"PlayerLeft\": \"kicked\" (FromUserID = who kicked, Payload = kicked player) or\n // \"timeout\" (host replaced via claimHost); absent for a plain leave\n Payload?: string; // opaque string; meaning depends on Type (see below)\n Ts?: number; // unix ms, server-assigned\n}\n```\n\n`Payload` by `Type`:\n\n- `\"Signal\"` → whatever string you passed to `sendSignal` (typically\n JSON-stringified `RTCSessionDescriptionInit` or `RTCIceCandidateInit`).\n- `\"Chat\"` → the raw chat text from `sendChat`.\n- `\"PlayerJoined\"` / `\"PlayerLeft\"` → the affected user's `UserID` (also\n duplicated in `FromUserID`).\n- `\"HostChanged\"` → the new host's `UserID`.\n- `\"RoomClosed\"` → the `RoomID` that was closed.\n- `\"MemberState\"` → a JSON string of `{ Ready, State }` — the changed\n member's new ready flag and state map (parse it, or just treat the\n envelope as a signal to re-`getRoom`).\n\nEnvelopes are delivered **at most once** per `poll` call (they're consumed by\nbeing read above `lastSeq`, not re-sent), but nothing deduplicates _across_\noverlapping poll calls if you mismanage your cursor — always advance\n`lastSeq` to the response's `MaxSeq`, never roll it back.\n\n---\n\n## Other responses\n\n```ts\ninterface KickMemberResponse {\n Removed?: boolean; // the player was a member and has been removed\n Banned?: boolean; // the player is now on the ban list\n}\n\ninterface UnbanMemberResponse {\n WasBanned?: boolean;\n}\n\ninterface LeaveRoomResponse {\n Closed?: boolean; // true if the room was closed/removed as a result\n NewHostUserID?: string; // set only when the host left and members remain\n}\n\ninterface PollResponse {\n Envelopes?: RealtimeEnvelope[]; // ordered by Seq ascending\n MaxSeq?: number; // max Seq among returned envelopes, or the sent LastSeq if none — always your next cursor\n}\n\ninterface PublishResponse {\n Seq?: number; // the seq assigned to the envelope you just published, for tracing/debugging\n}\n```\n\n---\n\n## Request + actions\n\nEvery call funnels through one `MultiplayerRequest` shape and a 13-value\n`MultiplayerAction` enum (`CreateRoom`, `JoinRoom`, `LeaveRoom`, `ListRooms`,\n`GetRoom`, `CloseRoom`, `Poll`, `SendSignal`, `SendChat`, `SetMemberState`,\n`ClaimHost`, `KickMember`, `UnbanMember`) —\nyou never construct either yourself; `MultiplayerService` builds the request\nand picks the action per method. Listed here only so error messages and field\nnames in server logs/network tabs are recognizable:\n\n```ts\ninterface MultiplayerRequest {\n RoomID?: string;\n RoomName?: string; // CreateRoom\n Topology?: MultiplayerTopology; // CreateRoom\n ChatMode?: MultiplayerChatMode; // CreateRoom\n MaxPlayers?: number; // CreateRoom\n Visibility?: RoomVisibility; // CreateRoom\n JoinCode?: string; // CreateRoom (sets it) / JoinRoom (submits it)\n TargetUserID?: string; // SendSignal / KickMember / UnbanMember\n SuccessorUserID?: string; // LeaveRoom: the host names who takes over\n Ban?: boolean; // KickMember: also ban\n SignalKind?: string; // SendSignal: \"offer\" | \"answer\" | \"candidate\"\n Payload?: string; // SendSignal\n ChatText?: string; // SendChat\n Ready?: boolean; // SetMemberState\n MemberState?: Record<string, string>; // SetMemberState\n LastSeq?: number; // Poll cursor\n Page?: number; // ListRooms\n PageSize?: number; // ListRooms\n}\n```\n\nPer-action rate limiting (server-side, not SDK-visible as distinct reasons —\nall surface as `reason: \"throttled\"` from the transport, or a plain\n`\"server\"` failure if you exceed the backend's own request lock): `Poll`,\n`SendSignal`, `SendChat`, and `SetMemberState` are treated as high-frequency\nrealtime traffic with a short per-user lock; `ListRooms`/`GetRoom` are\nread-only with a medium lock; `CreateRoom`/`JoinRoom`/`LeaveRoom`/`CloseRoom`\n(room lifecycle mutations) use the strictest lock. You don't need the exact\nnumbers to use the SDK correctly — just don't hot-loop any of these calls,\nand expect lifecycle actions (create/join/leave/close) to tolerate less\nback-to-back traffic than the realtime loop (poll/signal/chat/state).\n"
|
|
8
|
+
"content": "# Multiplayer data model — reference\n\nFull shape of the config (`MultiplayerDefinitions`), the room/member view\nmodels, the realtime envelope union, and the request/action surface. All of\nthese are **strictly typed in the SDK** and exported from `@idosgames/core`.\nThe zod schemas keep `.passthrough()`, so a field the backend adds later still\nround-trips as `unknown` extra data rather than being stripped. Field names\nare PascalCase (straight from the backend JSON).\n\nThere is no player-state shape to document here — Multiplayer has none. Every\nmodel below is either title-wide config or an ephemeral, per-room view.\n\n## Contents\n\n- [Config: MultiplayerDefinitions](#config-multiplayerdefinitions) — what rides in on the title's public config\n- [Room lifecycle state machine](#room-lifecycle-state-machine)\n- [RoomSnapshotResponse](#roomsnapshotresponse)\n- [RoomMemberView](#roommemberview)\n- [RoomListItem](#roomlistitem--roomspageresponse)\n- [RealtimeEnvelope + EnvelopeType](#realtimeenvelope--envelopetype)\n- [LeaveRoomResponse / PublishResponse / PollResponse](#other-responses)\n- [Request + actions](#request--actions)\n\n---\n\n## Config: MultiplayerDefinitions\n\nBackend: `MultiplayerDefinitions.cs`, stored at\n`TitlePublicConfigurationModel.Multiplayer`. Public-only data — no secrets.\nThere is no dedicated `getMultiplayerDefinitions()` fetch; this arrives with\nthe full title config (`client.title.getTitlePublicConfiguration()`, or\nalready present after the login bootstrap). See\n[Reading limits from config](../SKILL.md#reading-limits-from-config) in the\nmain skill.\n\n```ts\ninterface MultiplayerDefinitions {\n SystemState?: { Enabled?: boolean }; // title-wide feature switch\n DefaultTopology?: MultiplayerTopology; // \"Mesh\" | \"Host\"; default \"Mesh\"\n AllowCreatorTopologyOverride?: boolean; // default true\n DefaultChatMode?: MultiplayerChatMode; // \"ServerRelay\" | \"P2P\"; default \"ServerRelay\"\n AllowCreatorChatOverride?: boolean; // default true\n MaxPlayersPerRoom?: number; // hard cap; default 8\n RoomTtlSeconds?: number; // Redis key TTL, renewed on every interaction; default 3600\n MaxRoomLifetimeSeconds?: number; // hard lifetime since creation, NOT renewed; on expiry RoomClosed (Kind \"expired\"); 0 = none\n HeartbeatIntervalMs?: number; // recommended client poll interval; default 1000 — NOT enforced\n MemberTimeoutMs?: number; // \"presumed offline\" threshold; default 15000. The server evicts nobody on its own — except a HOST silent this long, via claimHost\n MaxSignalPayloadBytes?: number; // default 16384\n MaxChatTextLength?: number; // characters; default 500\n RoomLogCap?: number; // how many broadcast envelopes the room-log tail keeps; default 500\n IceServers?: IceServer[]; // STUN only here (public config!); empty/absent -> public STUN fallback. TURN comes per member from the engine (Cloudflare), never from this list\n}\n\ninterface IceServer {\n Urls?: string; // e.g. \"stun:stun.l.google.com:19302\" or \"turn:turn.example.com:3478\"\n Username?: string; // TURN only\n Credential?: string; // TURN only\n}\n```\n\n`MultiplayerTopology`: `\"Mesh\"` (every player connects to every other —\nsmall rooms) | `\"Host\"` (star topology, everyone connects only to the host).\n\n`MultiplayerChatMode`: `\"ServerRelay\"` (chat broadcast through the room-log,\nworks in lobby and as a fallback) | `\"P2P\"` (server does not process chat at\nall — send it over your own DataChannel).\n\n`RoomVisibility`: `\"Public\"` (appears in `listRooms`) | `\"Private\"` (not\nlisted; join by `RoomID` + join code only).\n\n---\n\n## Room lifecycle state machine\n\nA room's `Status` (surfaced as `RoomSnapshotResponse.Status`) is a plain\nstring, driven by these transitions (backend: `RoomService.cs` +\n`RedisService.Realtime.cs`):\n\n- **created** → `\"Open\"`. `createRoom` sets it; the caller becomes both\n `OwnerUserID` and `HostUserID`.\n- **join** — allowed only while `Open` and under `MaxPlayers`; rejected with\n `\"Room is closed.\"` / `\"Room is full.\"` otherwise. A private room additionally\n requires the join code to match (SHA-256 hash comparison server-side —\n the raw code is never stored).\n- **leave** — if the leaving member was the last one, the room is deleted\n outright (`Closed: true` in the `LeaveRoomResponse`, no `RoomClosed`\n envelope is published — there's nobody left to receive it). If members\n remain and the leaver was the host, a new host is picked from the\n remaining members (`NewHostUserID`) and a `HostChanged` envelope is\n broadcast; the room stays `Open`.\n- **leave with a successor** — a leaving host may pass `successorUserID`; if\n that player is still a member they become host (otherwise the server picks\n one). `RealtimeSession` does this on `leave()` and on page close.\n- **claimHost** — a member takes the host role when the host has not polled\n for `MemberTimeoutMs`: the silent host is removed, `PlayerLeft` (Kind\n `\"timeout\"`) and `HostChanged` are broadcast. A compare-and-set in Redis\n guarantees one winner; a still-polling host → `\"The host is still active.\"`.\n- **kick / ban** (`kickMember`, owner or current host; never the host or the\n owner themselves) — `PlayerLeft` (Kind `\"kicked\"`). With `ban` the player is\n added to the room's ban list, so `joinRoom` answers `\"You were removed from\nthis room.\"`. The list survives host changes and dies with the room;\n `unbanMember` removes an entry.\n- **close** (`closeRoom`, owner or current host only) → a `RoomClosed`\n envelope is broadcast _before_ the room is marked `Closed`, so in-flight\n pollers still receive the notice; the room's keys then live out their\n remaining TTL rather than being deleted immediately (so late polls still\n resolve cleanly instead of hitting \"gone\").\n- **idle expiry** — every interaction (`poll`, `sendSignal`, `sendChat`,\n `setMemberState`, join/leave) renews the room's Redis TTL\n (`RoomTtlSeconds`). Stop interacting and the room silently disappears; a\n subsequent `poll` on it fails with `\"Room no longer exists.\"` (internally\n the `\"gone\"` code) rather than describing expiry explicitly.\n\nThere is no separate \"starting\"/\"in-progress\" server-tracked status — once\npeers have exchanged signals and opened their DataChannel, that phase is\nentirely off the server's radar. If your game needs a \"match started\" state,\nmodel it yourself via `setMemberState`'s free-form `MemberState` map or your\nown signaling payloads.\n\n---\n\n## RoomSnapshotResponse\n\nReturned by `createRoom`, `joinRoom`, and `getRoom`.\n\n```ts\ninterface RoomSnapshotResponse {\n RoomID?: string;\n Name?: string;\n HostUserID?: string; // current host; may differ from OwnerUserID after migration\n OwnerUserID?: string; // original creator; fixed for the room's lifetime\n Topology?: MultiplayerTopology;\n ChatMode?: MultiplayerChatMode;\n Visibility?: RoomVisibility;\n Status?: string; // \"Open\" | \"Closed\"\n MaxPlayers?: number;\n Members?: RoomMemberView[]; // ordered by JoinedAt ascending\n IceServers?: IceServer[]; // resolved: title STUN (or public fallback) + per-member TURN credentials when the engine has Cloudflare TURN\n Seq?: number; // starting poll cursor for this room\n BannedUserIDs?: string[]; // the ban list — only in the host's / owner's snapshot\n}\n```\n\n`JoinCode` is deliberately never returned in any response — only its SHA-256\nhash is stored server-side, checked against what `joinRoom` submits.\n\n---\n\n## RoomMemberView\n\nOne room member: static public profile fields plus mutable lobby state.\n\n```ts\ninterface RoomMemberView {\n UserID?: string;\n Username?: string;\n AvatarUrl?: string;\n Level?: number; // snapshotted from the player's public profile at join time\n Power?: number; // snapshotted from the player's public profile at join time\n Ready?: boolean; // mutated via setMemberState\n State?: Record<string, string>; // free-form lobby metadata, mutated via setMemberState\n JoinedAt?: number; // unix ms\n IsHost?: boolean; // computed on read: UserID === room's current HostUserID\n LastSeenMs?: number; // unix ms of last heartbeat (poll); compare against MemberTimeoutMs yourself\n}\n```\n\n`Level`/`Power` are a snapshot taken from the player's public profile\n(`UserPublicDataModel`) at the moment they joined — they do **not** live-update\nif the player's Character Power changes mid-room. Re-`joinRoom` (or a fresh\n`getRoom`) is the only way to refresh them; there's no push for profile\nchanges into an existing member record.\n\n`IsHost` and `LastSeenMs` are computed server-side on every read (from\n`hostUserId` in room meta and the presence Sorted Set respectively) — they\nare not stored on the member record itself.\n\n---\n\n## RoomListItem + RoomsPageResponse\n\nReturned by `listRooms`. A deliberately thin projection — no `Members`, no\njoin code, no `Seq` — for cheap listing pages.\n\n```ts\ninterface RoomListItem {\n RoomID?: string;\n Name?: string;\n HostUserID?: string;\n Topology?: MultiplayerTopology;\n ChatMode?: MultiplayerChatMode;\n MaxPlayers?: number;\n MemberCount?: number;\n CreatedAt?: number; // unix ms\n}\n\ninterface RoomsPageResponse {\n Rooms?: RoomListItem[];\n Page?: number;\n PageSize?: number; // server-echoed; clamped to <= 50 regardless of what you requested\n HasMore?: boolean;\n}\n```\n\nOnly `Public`-visibility, currently-`Open` rooms ever appear here — private\nrooms and closed rooms are excluded at the index level, not filtered\nclient-side.\n\n---\n\n## RealtimeEnvelope + EnvelopeType\n\nThe unified delivery item returned by `poll`. `Seq` is a per-room, strictly\nincreasing counter shared by both the broadcast log and every member's\ndirected inbox — that's what makes a single `lastSeq` cursor sufficient to\ndrain all of it.\n\n```ts\ntype EnvelopeType =\n | \"Signal\" // directed: SDP offer/answer or ICE candidate\n | \"Chat\" // broadcast: ChatMode=ServerRelay message\n | \"PlayerJoined\" // broadcast: someone joined\n | \"PlayerLeft\" // broadcast: someone left\n | \"HostChanged\" // broadcast: host migrated\n | \"RoomClosed\" // broadcast: room closed by owner/host\n | \"MemberState\"; // broadcast: someone's Ready/State changed\n\ninterface RealtimeEnvelope {\n Seq?: number;\n Type?: EnvelopeType;\n FromUserID?: string;\n ToUserID?: string; // present only on directed \"Signal\" envelopes\n Kind?: string; // \"Signal\": free-form (\"offer\" | \"answer\" | \"candidate\" by convention; RealtimeSession uses \"rtc\");\n // \"PlayerLeft\": \"kicked\" (FromUserID = who kicked, Payload = kicked player) or\n // \"timeout\" (host replaced via claimHost); absent for a plain leave\n Payload?: string; // opaque string; meaning depends on Type (see below)\n Ts?: number; // unix ms, server-assigned\n}\n```\n\n`Payload` by `Type`:\n\n- `\"Signal\"` → whatever string you passed to `sendSignal` (typically\n JSON-stringified `RTCSessionDescriptionInit` or `RTCIceCandidateInit`).\n- `\"Chat\"` → the raw chat text from `sendChat`.\n- `\"PlayerJoined\"` / `\"PlayerLeft\"` → the affected user's `UserID` (also\n duplicated in `FromUserID`).\n- `\"HostChanged\"` → the new host's `UserID`.\n- `\"RoomClosed\"` → the `RoomID` that was closed.\n- `\"MemberState\"` → a JSON string of `{ Ready, State }` — the changed\n member's new ready flag and state map (parse it, or just treat the\n envelope as a signal to re-`getRoom`).\n\nEnvelopes are delivered **at most once** per `poll` call (they're consumed by\nbeing read above `lastSeq`, not re-sent), but nothing deduplicates _across_\noverlapping poll calls if you mismanage your cursor — always advance\n`lastSeq` to the response's `MaxSeq`, never roll it back.\n\n---\n\n## Other responses\n\n```ts\ninterface KickMemberResponse {\n Removed?: boolean; // the player was a member and has been removed\n Banned?: boolean; // the player is now on the ban list\n}\n\ninterface UnbanMemberResponse {\n WasBanned?: boolean;\n}\n\ninterface LeaveRoomResponse {\n Closed?: boolean; // true if the room was closed/removed as a result\n NewHostUserID?: string; // set only when the host left and members remain\n}\n\ninterface PollResponse {\n Envelopes?: RealtimeEnvelope[]; // ordered by Seq ascending\n MaxSeq?: number; // max Seq among returned envelopes, or the sent LastSeq if none — always your next cursor\n}\n\ninterface PublishResponse {\n Seq?: number; // the seq assigned to the envelope you just published, for tracing/debugging\n}\n```\n\n---\n\n## Request + actions\n\nEvery call funnels through one `MultiplayerRequest` shape and a 13-value\n`MultiplayerAction` enum (`CreateRoom`, `JoinRoom`, `LeaveRoom`, `ListRooms`,\n`GetRoom`, `CloseRoom`, `Poll`, `SendSignal`, `SendChat`, `SetMemberState`,\n`ClaimHost`, `KickMember`, `UnbanMember`) —\nyou never construct either yourself; `MultiplayerService` builds the request\nand picks the action per method. Listed here only so error messages and field\nnames in server logs/network tabs are recognizable:\n\n```ts\ninterface MultiplayerRequest {\n RoomID?: string;\n RoomName?: string; // CreateRoom\n Topology?: MultiplayerTopology; // CreateRoom\n ChatMode?: MultiplayerChatMode; // CreateRoom\n MaxPlayers?: number; // CreateRoom\n Visibility?: RoomVisibility; // CreateRoom\n JoinCode?: string; // CreateRoom (sets it) / JoinRoom (submits it)\n TargetUserID?: string; // SendSignal / KickMember / UnbanMember\n SuccessorUserID?: string; // LeaveRoom: the host names who takes over\n Ban?: boolean; // KickMember: also ban\n SignalKind?: string; // SendSignal: \"offer\" | \"answer\" | \"candidate\"\n Payload?: string; // SendSignal\n ChatText?: string; // SendChat\n Ready?: boolean; // SetMemberState\n MemberState?: Record<string, string>; // SetMemberState\n LastSeq?: number; // Poll cursor\n Page?: number; // ListRooms\n PageSize?: number; // ListRooms\n}\n```\n\nPer-action rate limiting (server-side, not SDK-visible as distinct reasons —\nall surface as `reason: \"throttled\"` from the transport, or a plain\n`\"server\"` failure if you exceed the backend's own request lock): `Poll`,\n`SendSignal`, `SendChat`, and `SetMemberState` are treated as high-frequency\nrealtime traffic with a short per-user lock; `ListRooms`/`GetRoom` are\nread-only with a medium lock; `CreateRoom`/`JoinRoom`/`LeaveRoom`/`CloseRoom`\n(room lifecycle mutations) use the strictest lock. You don't need the exact\nnumbers to use the SDK correctly — just don't hot-loop any of these calls,\nand expect lifecycle actions (create/join/leave/close) to tolerate less\nback-to-back traffic than the realtime loop (poll/signal/chat/state).\n"
|
|
9
9
|
}
|
|
10
10
|
]
|
|
11
11
|
}
|