@idosgames/mcp 0.1.46 → 0.1.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1 -1
- package/package.json +1 -1
- package/registry/host.json +1 -1
- package/registry/index.json +159 -149
- package/registry/modules/ai-media-app.json +77 -0
- package/registry/modules/ai.json +3 -3
- package/registry/modules/board-game.json +3 -3
- package/registry/modules/boosts.json +3 -3
- package/registry/modules/character.json +3 -3
- package/registry/modules/chat.json +3 -3
- package/registry/modules/collections.json +3 -3
- package/registry/modules/coop-events.json +3 -3
- package/registry/modules/craft.json +3 -3
- package/registry/modules/deals.json +3 -3
- package/registry/modules/events.json +6 -6
- package/registry/modules/friends.json +3 -3
- package/registry/modules/hero-run.json +3 -3
- package/registry/modules/idle-rpg.json +3 -3
- package/registry/modules/inventory.json +3 -3
- package/registry/modules/invite.json +3 -3
- package/registry/modules/kit-game.json +17 -11
- package/registry/modules/leaderboard.json +3 -3
- package/registry/modules/lootboxes.json +3 -3
- package/registry/modules/mailbox.json +3 -3
- package/registry/modules/marketplace.json +3 -3
- package/registry/modules/multiplayer.json +3 -3
- package/registry/modules/premium.json +3 -3
- package/registry/modules/profile.json +3 -3
- package/registry/modules/pvp.json +3 -3
- package/registry/modules/quests.json +3 -3
- package/registry/modules/rewards.json +3 -3
- package/registry/modules/season.json +6 -6
- package/registry/modules/store.json +3 -3
- package/registry/modules/tutorials.json +6 -6
- package/registry/modules/video-studio.json +3 -3
- package/registry/modules/voxelcraft.json +3 -3
- package/registry/modules/workshop.json +3 -3
- package/registry/skills/ai-generation-system.json +1 -1
- package/registry/skills/idosgames-compose-modules.json +1 -1
- package/registry/skills/idosgames-getting-started.json +2 -2
- package/registry/skills/idosgames-module-contract.json +2 -2
- package/registry/modules/mini-game.json +0 -69
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-generation-system",
|
|
3
3
|
"description": "Add InApp AI generation to a game on the iDosGames TypeScript SDK (@idosgames/core) via client.ai (AIService): AI NPC dialogue and other text generation (optionally with images in — e.g. build a voxel world from a picture the player uploads), image generation and image editing, video, speech (TTS), music and 3D models — every generation is a job the caller waits for (client.ai.waitForGeneration). Use this whenever the user wants AI-generated content at runtime in a game built on the iDosGames TS SDK or its templates (board-game, idle-rpg, voxelcraft) — AI NPCs, generated skins / avatars / items / levels / worlds, voice lines, generated music, 3D props — or touches client.ai, AIService, AIGenerationView, AIPublicDefinitions, AIPublicFeature, AITextInput, AIImageInput, AIInputImage, AIModality, AIAction, AIErrorCode, isAIGenerationTerminal or parseAIErrorCode — even if they don't name the module.",
|
|
4
|
-
"content": "---\nname: ai-generation-system\ndescription: >-\n Add InApp AI generation to a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.ai (AIService): AI NPC dialogue and other text\n generation (optionally with images in — e.g. build a voxel world from a\n picture the player uploads), image generation and image editing, video,\n speech (TTS), music and 3D models — every generation is a job the caller\n waits for (client.ai.waitForGeneration). Use this whenever the user wants AI-generated content at\n runtime in a game built on the iDosGames TS SDK or its templates (board-game,\n idle-rpg, voxelcraft) — AI NPCs, generated skins / avatars / items / levels /\n worlds, voice lines, generated music, 3D props — or touches client.ai,\n AIService, AIGenerationView, AIPublicDefinitions, AIPublicFeature,\n AITextInput, AIImageInput, AIInputImage, AIModality, AIAction, AIErrorCode,\n isAIGenerationTerminal or parseAIErrorCode — even if they don't name the\n module.\n---\n\n# AI generation system (iDosGames TS SDK)\n\nThe AI module lets a **player** generate content at runtime — text, images,\nvideo, speech, music, 3D — through the game backend. The title's publisher\ndefines **features** (each with a stable `FeatureKey` such as `\"npc-chat\"`,\n`\"voxel-world\"`, `\"skin-gen\"`) in the dashboard or through the backend\ntitle-config MCP (`save_ai`). A feature fixes the modality, the model, the\nsystem prompt, the limits and the in-game price for the player. The game only\nnames the feature and sends the player's input.\n\nIt is **server-authoritative and publisher-paid**:\n\n- The client **never** picks a model, a prompt or a price. There is no such\n field on the wire, and there must not be: generation runs on the publisher's\n credits, so a client that chose the model would choose the publisher's bill.\n- The provider cost is charged to the **publisher's** credits. On top of that\n the **player** may pay in-game resources (the feature's `PriceOptions`) —\n returned on the generation as `Charge` and applied to the local cache by the\n SDK.\n- Failures after a charge are refunded in full by the server\n (`PlayerRefunded: true` on the generation).\n\nThis skill is for **using** `client.ai`. Creating or changing features is\npublisher configuration (dashboard / `save_ai`); the client cannot do it.\n\n## Key data entities\n\n1. **`AIPublicDefinitions`** (per title) — the sanitized feature list from\n `getDefinitions()`: for each `FeatureKey`, its `Modality`, `Limits`,\n `PriceOptions`, what the client may send\n (`AllowImages`, `MaxInputImages`, `MaxInputImageBytes`, `AllowedImageTypes`,\n `MaxImages`, `AllowEdit`, `AllowReferenceImage`, `MaxPromptChars`,\n `LockBehavior`, `HistoryMessages`, `MaxTokens`) and UI defaults. No prompt,\n no model. Disabled features are simply absent. Cached as config section\n `\"AI\"` — it is **not** part of the title config, you must call\n `getDefinitions()`.\n2. **`AIGenerationView`** — one generation as the player sees it:\n `GenerationID`, `FeatureKey`, `Modality`, `Status`\n (`\"pending\" | \"running\" | \"completed\" | \"failed\"`), `Progress` (0..100, async\n jobs), `OutputText`, `Assets[]` (`{ Url, ContentType, Variant }`),\n `Error { Code, Message }`, `Charge`, `PlayerRefunded`, `CreatedAt`,\n `UpdatedAt`.\n3. **No player-state slot.** Limit counters live on the server; history is\n `listGenerations()` / `getGeneration()`.\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 ai = client.ai; // the AIService\nconst defs = await ai.getDefinitions();\n```\n\nEvery method needs a session; without one it returns\n`{ ok: false, reason: \"unauthorized\" }` — it never throws.\n\n## Methods\n\nAll return `Promise<OperationResult<T>>`. The `options` argument of every\ngenerate method is `{ relatedEntityID?, selectedOptionID? }` (see\nIdempotency).\n\n| Method | Purpose | `data` |\n| --------------------------------------------- | --------------------------------------------------------------- | ---------------------- |\n| `getDefinitions()` | The title's features (sanitized). | `AIPublicDefinitions` |\n| `generateText(featureKey, input, options?)` | Submit a text job → `pending`. | `AIGenerationView` |\n| `getText(generationID)` | Poll a text job. | `AIGenerationView` |\n| `generateImage(featureKey, input, options?)` | Submit an image job → `pending`. | `AIGenerationView` |\n| `editImage(featureKey, input, options?)` | Submit an image edit (feature with `AllowEdit`) → `pending`. | `AIGenerationView` |\n| `getImage(generationID)` | Poll an image / edit job. | `AIGenerationView` |\n| `generateVideo(featureKey, input, options?)` | Submit a video job → `running`. | `AIGenerationView` |\n| `getVideo(generationID)` | Poll a video job. | `AIGenerationView` |\n| `generateAudio(featureKey, input, options?)` | Submit a speech (TTS) job → `pending`. | `AIGenerationView` |\n| `getAudio(generationID)` | Poll a speech job. | `AIGenerationView` |\n| `generateMusic(featureKey, input, options?)` | Submit a music job → `pending`. | `AIGenerationView` |\n| `getMusic(generationID)` | Poll a music job. | `AIGenerationView` |\n| `generateThreeD(featureKey, input, options?)` | Submit a 3D job → `running`. | `AIGenerationView` |\n| `getThreeD(generationID)` | Poll a 3D job. | `AIGenerationView` |\n| `waitForGeneration(view or id, options?)` | Poll the job with its own action until it is final. | `AIGenerationView` |\n| `listGenerations(query?)` | History page (`{ Modality, FeatureKey, Status, Skip, Limit }`). | `AIGenerationListView` |\n| `getGeneration(generationID)` | One generation (history). | `AIGenerationView` |\n| `isTerminal(status)` | `true` once polling can stop. | `boolean` |\n\n**Every generation is a job** (since 13.09.2026). A generate call only submits\nit and answers `pending` (text, image, edit, speech, music — queued on the\nserver) or `running` (video, 3D — submitted to the provider); the server never\nwaits for the model inside the request. Get the result with\n`waitForGeneration(view)` — it polls the right action until the status is\nfinal and returns the final view.\n\n### Two different kinds of failure — handle both\n\n```ts\nimport { parseAIErrorCode, AIErrorCode } from \"@idosgames/core\";\n\nconst res = await client.ai.generateImage(\"skin-gen\", { Prompt: text });\n\nif (!res.ok) {\n // 1. The REQUEST was refused (nothing generated, nothing charged) — or a\n // transport problem. Server refusals arrive as \"AI_CODE: safe message\".\n const code = parseAIErrorCode(res.error);\n if (code === AIErrorCode.LimitReached)\n return showToast(\"Come back tomorrow!\");\n if (code === AIErrorCode.Unavailable)\n return showToast(\"Not available right now.\");\n if (res.reason === \"connection\") return offerRetry(); // see Idempotency\n return showToast(res.error);\n}\n\nconst done = await client.ai.waitForGeneration(res.data);\nif (!done.ok) return showToast(\"Still working — check back later.\"); // timed out / aborted; the job keeps running\n\nif (done.data.Status === \"failed\") {\n // 2. The generation RAN and failed. The calls succeeded; PlayerRefunded says\n // the player got the price back. Show Error.Message, branch on Error.Code.\n return showToast(done.data.Error?.Message ?? \"Generation failed.\");\n}\n\nshowImage(done.data.Assets?.[0]?.Url);\n```\n\n`reason` is one of `\"client\"` (missing `featureKey` / `generationID`),\n`\"unauthorized\"`, `\"throttled\"`, `\"connection\"`, `\"validation\"`, `\"server\"`.\n\n## Recipes\n\n### AI NPC dialogue (text with history)\n\n```ts\nconst history: AIChatMessage[] = [];\n\nasync function say(playerLine: string) {\n const res = await client.ai.generateText(\"npc-chat\", {\n Prompt: playerLine,\n Messages: history, // earlier turns; the server keeps the feature's HistoryMessages last ones\n });\n if (!res.ok) return null;\n const done = await client.ai.waitForGeneration(res.data, {\n intervalMs: 1000,\n });\n if (!done.ok || done.data.Status !== \"completed\") return null;\n\n history.push({ Role: \"user\", Content: playerLine });\n history.push({ Role: \"assistant\", Content: done.data.OutputText ?? \"\" });\n return done.data.OutputText;\n}\n```\n\nThe NPC's persona is the feature's system prompt on the server — don't put it\nin `Prompt`. A reply takes a few seconds: show a \"thinking…\" state and disable\nthe send button. `intervalMs: 1000` shows a short reply quickly; the wait slows\ndown on its own for a long answer.\n\n### Picture in, text out — build a voxel world from a picture\n\nFor a Text feature with `AllowImages`, attach images to the prompt. Check the\nfeature's constraints before uploading so the player gets an instant message\ninstead of a round trip:\n\n```ts\nconst feature = defs.ok ? defs.data.Features?.[\"voxel-world\"] : undefined;\n\nasync function fileToInput(file: File): Promise<AIInputImage | string> {\n if (!feature?.AllowImages) return \"This feature does not take pictures.\";\n const types = feature.AllowedImageTypes ?? [\n \"image/png\",\n \"image/jpeg\",\n \"image/webp\",\n ];\n if (!types.includes(file.type)) return \"Use a PNG, JPEG or WebP picture.\";\n if (feature.MaxInputImageBytes && file.size > feature.MaxInputImageBytes)\n return \"The picture is too large.\";\n const dataUrl = await new Promise<string>((resolve, reject) => {\n const r = new FileReader();\n r.onload = () => resolve(String(r.result));\n r.onerror = () => reject(r.error);\n r.readAsDataURL(file);\n });\n return { Base64: dataUrl, ContentType: file.type }; // a data-URL is accepted as is\n}\n\nconst image = await fileToInput(file);\nif (typeof image === \"string\") return showToast(image);\n\nconst res = await client.ai.generateText(\"voxel-world\", {\n Prompt: \"Turn this picture into a voxel world. Answer with JSON only.\",\n Images: [image],\n});\nif (!res.ok) return showToast(res.error);\n\n// A whole world is a long answer (minutes): wait with progress, stop when the screen closes.\nconst done = await client.ai.waitForGeneration(res.data, {\n signal: screenAbort.signal,\n});\n\nconst world = parseWorldJson(done.ok ? done.data.OutputText : null); // validate it — it is model output\n```\n\nThe expected output format (\"JSON with blocks: [...]\") belongs in the feature's\nsystem prompt, so every player gets the same contract. Always validate the\nparsed structure: it is model output, not trusted data.\n\n### Generate an image, then edit it\n\n```ts\nconst img = await client.ai.generateImage(\"skin-gen\", {\n Prompt: \"a red dragon armour skin\",\n Size: \"1024x1024\", // ignored if the feature has LockBehavior\n});\nif (!img.ok) return;\nconst made = await client.ai.waitForGeneration(img.data);\nif (!made.ok || made.data.Status !== \"completed\") return;\nconst url = made.data.Assets?.[0]?.Url;\n\n// Feed a generated asset back in by URL — only assets THIS title generated are accepted.\nconst edit = await client.ai.editImage(\"skin-gen\", {\n Prompt: \"make it blue\",\n Images: [{ AssetUrl: url }],\n});\nconst edited = edit.ok ? await client.ai.waitForGeneration(edit.data) : edit;\n```\n\n### Waiting for a job — `waitForGeneration`\n\nNothing polls on its own (platform convention, as with chat): each poll is an\nAPI call billed to the publisher, so wait only while the player needs the\nresult.\n\n```ts\nconst job = await client.ai.generateVideo(\"trailer\", {\n Prompt: \"a dragon over a castle\",\n Seconds: 8,\n});\nif (job.ok) {\n const done = await client.ai.waitForGeneration(job.data, {\n signal: screenAbort.signal, // stop when the screen closes\n onUpdate: (v) => renderProgress(v.Progress), // 0..100 when the provider reports it\n });\n if (done.ok && done.data.Status === \"completed\")\n playVideo(done.data.Assets?.[0]?.Url);\n}\n```\n\n- Options: `intervalMs` (first pause, default 2000; grows ×1.5 up to\n `maxIntervalMs`, default 5000), `timeoutMs` (default 30 minutes), `signal`,\n `onUpdate`. A dropped connection does not end the wait; a timeout or the\n signal ends it with `reason: \"client\"` — the job keeps running on the server.\n- An abandoned job is not lost: save its `GenerationID` and resume later with\n `waitForGeneration(id)` (it reads the generation once to learn its modality),\n or find it in `listGenerations()`.\n- Polling by hand: use the action of the modality (`getText`, `getImage`,\n `getAudio`, `getMusic`, `getVideo`, `getThreeD`) — only it moves a provider\n job forward. `isTerminal` treats anything other than `pending` / `running`\n as final, so a status added later never makes a loop spin forever.\n- 3D `Assets` may hold several files — pick by `Variant` / `ContentType`: the\n model's `Variant` is its extension (`glb`, `fbx`, `splat`…), images are\n `thumbnail`, `front` / `left` / `back` / `right`, `uv`, `mask`. `rig-check`\n has no file at all: its answer is `OutputText` JSON.\n- Chains (`rig` → `retarget`, a model → `texture` / `convert`…) pass the earlier\n result as `SourceGenerationId` — the player's own finished generation, never\n a raw vendor id or a link. The feature, not the player, picks the operation.\n\n### Speech and music\n\n```ts\nconst voice = await client.ai.generateAudio(\"npc-voice\", {\n Text: \"Welcome, traveller!\",\n});\nconst spoken = voice.ok ? await client.ai.waitForGeneration(voice.data) : voice;\nif (spoken.ok && spoken.data.Status === \"completed\")\n new Audio(spoken.data.Assets?.[0]?.Url).play();\n\nconst track = await client.ai.generateMusic(\"level-music\", {\n Prompt: \"calm lofi for a farm level\",\n});\nconst music = track.ok ? await client.ai.waitForGeneration(track.data) : track;\n```\n\n### Idempotency and retries\n\nEach generate call sends a `RelatedEntityID`. Without `options` the SDK mints\na fresh one per call, so **two calls are two paid generations**. A retry with\nthe **same** key returns the original generation and is never charged twice.\n\nGenerate calls use a long timeout and **no automatic transport retry** (a\nresend could reach the server while the first attempt is still running). A\nnetwork failure therefore surfaces as `reason: \"connection\"`. To retry\nsafely, own the key:\n\n```ts\nconst key = crypto.randomUUID();\nlet res = await client.ai.generateImage(\"skin-gen\", input, {\n relatedEntityID: key,\n});\nif (!res.ok && res.reason === \"connection\")\n res = await client.ai.generateImage(\"skin-gen\", input, {\n relatedEntityID: key,\n }); // same generation\n```\n\nA key must be 1–128 characters of `A–Z a–z 0–9 _ -` (a UUID fits). Anything\nelse fails locally with `reason: \"client\"`: the server would strip the other\ncharacters and cut at 128, and two different keys could silently become one\ngeneration. Don't add the feature or player to the key — the server already\nscopes keys by title and player.\n\n### Pay with a specific option\n\n```ts\nconst feature = defs.ok ? defs.data.Features?.[\"skin-gen\"] : undefined;\n// feature.PriceOptions: Record<OptionID, PriceOption> — empty/absent = free for the player.\nawait client.ai.generateImage(\"skin-gen\", input, { selectedOptionID: \"gems\" });\n```\n\nOmit `selectedOptionID` to pay with the first available option. Filter options\nby platform with `client.checkout` before showing them, like any price.\n\n### Pay with a store purchase (IAP)\n\nAn option may be a store purchase (a `Purchase` entry): **one purchase pays for\nexactly one generation.** Buy the consumable in the native store, keep the\ntransaction PENDING, and send its receipt:\n\n```ts\nconst res = await client.ai.generateImage(\"skin-gen\", input, {\n selectedOptionID: \"store\",\n payment: { Store: \"GooglePlay\", Receipt: receipt },\n});\nconst done = res.ok ? await client.ai.waitForGeneration(res.data) : res;\nif (done.ok && done.data.PaymentSettled === true) finishStoreTransaction(); // consume now\n```\n\n- `PaymentSettled: true` — the generation is ready, the purchase is counted:\n finish (consume) the store transaction.\n- `PaymentSettled: false` on a job still in progress (every generation until it\n completes) — keep the transaction pending and finish it when a poll reports `true`.\n- `PaymentSettled: false` on a **failed** generation — keep the transaction and\n retry with the SAME receipt and a NEW `relatedEntityID`: the retry is free.\n- Re-sending a receipt that already paid returns that generation — it never\n pays for a second one. A publisher out of credits refuses BEFORE the receipt\n is taken, so the purchase is not burnt.\n\n### History\n\n```ts\nconst page = await client.ai.listGenerations({\n Modality: \"Image\",\n Status: \"completed\",\n Limit: 20,\n});\nconst one = await client.ai.getGeneration(generationID);\n```\n\nHistory reads never change the cache and emit no events.\n\n## Events\n\nSubscribe with `client.on(...)` (returns an unsubscribe fn):\n\n- `ai:definitionsLoaded` → `AIPublicDefinitions`\n- `ai:generationUpdated` → `AIGenerationView` — every generate and poll response\n- `ai:generationCompleted` → `AIGenerationView` — a generate/poll came back `completed`\n- `ai:generationFailed` → `AIGenerationView` — a generate/poll came back `failed`\n\nApplying a `Charge` also fires the usual resource events\n(`user:virtualCurrencyUpdated`, `user:inventoryUpdated`,\n`user:eventTokenUpdated`, `user:anyUpdated`).\n\n## Error codes\n\n`AIErrorCode` constants; a refusal's `error` reads `\"CODE: message\"`\n(`parseAIErrorCode`), a failed generation carries `Error.Code`.\n\n| Code | Meaning | What to show |\n| ---------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------ |\n| `AI_DISABLED` | AI is off for the title | hide AI UI |\n| `AI_FEATURE_NOT_FOUND` / `AI_FEATURE_DISABLED` | no such / disabled feature | hide the entry point; refresh `getDefinitions()` |\n| `AI_WRONG_ACTION` | action ≠ feature modality | a bug in the game: fix the call |\n| `AI_NOT_AVAILABLE_FOR_PLAYER` | the feature's audience gate excludes this player | hide the entry point |\n| `AI_FEATURE_CLOSED` | outside the feature's schedule | \"not available now\" |\n| `AI_LIMIT_REACHED` / `AI_COOLDOWN` | the feature's `Limits` | \"come back later\" |\n| `AI_BAD_INPUT` | too long, too many / too large / wrong images, a foreign `AssetUrl` | the message |\n| `AI_PROMPT_REJECTED` | blocked terms | ask to rephrase |\n| `AI_UNAVAILABLE` | the publisher's side (credits, plan, caps) — reason deliberately hidden | a neutral \"not available right now\" |\n| `AI_GENERATION_FAILED` | the provider failed; everything charged is returned | \"try again\" |\n| `AI_GENERATION_NOT_FOUND` | unknown `GenerationID` | drop the stored id |\n\nTreat any other `AI_…` code generically — newer backends may add codes.\n\n## Gotchas\n\n- **A generate response is not the result.** It is `pending` / `running`; wait\n with `waitForGeneration`, then check the final `Status` — a failed generation\n is a successful call (`result.ok` ≠ \"generated\").\n- **One generate per action at a time.** A second `generateImage` while one is\n in flight is rejected with `reason: \"throttled\"` (the server serializes\n them per player and action anyway). Disable the button while waiting. Polls\n of _different_ jobs are not throttled against each other.\n- **The Charge is handled for you.** It is applied once, from the generate\n response. A generation with `PlayerRefunded: true` (failed after charging;\n the server returned the price) is never applied — its `Charge` still shows\n what was taken — and if this session had already applied it (every job is\n charged at submit), the SDK re-reads the inventory before emitting the\n event. A failed generation WITHOUT `PlayerRefunded` keeps its charge. Don't\n apply `Charge` yourself.\n- **Behaviour overrides are optional and may be ignored.** Size, quality,\n voice, image count… apply only when the feature has no `LockBehavior`;\n `MaxTokens` can only _lower_ the feature's value; `MaxPromptChars` counts the\n history too.\n- **Input images are checked by file signature**, not by the declared\n `ContentType`; `AssetUrl` must point to an asset this title generated.\n- **Don't cache `Assets[].Url` forever as the only copy** if the player owns\n the result — store the `GenerationID` and re-read it when needed.\n- **Unknown modality on an old SDK** falls back to `\"Text\"` when parsing\n definitions; the server still enforces the real one (`AI_WRONG_ACTION`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every request/response\nfield, the feature fields and their defaults (no platform caps — only the\nmodel's own limits), the server-side order of checks,\nand how charges, refunds and async jobs behave.\n",
|
|
4
|
+
"content": "---\nname: ai-generation-system\ndescription: >-\n Add InApp AI generation to a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.ai (AIService): AI NPC dialogue and other text\n generation (optionally with images in — e.g. build a voxel world from a\n picture the player uploads), image generation and image editing, video,\n speech (TTS), music and 3D models — every generation is a job the caller\n waits for (client.ai.waitForGeneration). Use this whenever the user wants AI-generated content at\n runtime in a game built on the iDosGames TS SDK or its templates (board-game,\n idle-rpg, voxelcraft) — AI NPCs, generated skins / avatars / items / levels /\n worlds, voice lines, generated music, 3D props — or touches client.ai,\n AIService, AIGenerationView, AIPublicDefinitions, AIPublicFeature,\n AITextInput, AIImageInput, AIInputImage, AIModality, AIAction, AIErrorCode,\n isAIGenerationTerminal or parseAIErrorCode — even if they don't name the\n module.\n---\n\n# AI generation system (iDosGames TS SDK)\n\nThe AI module lets a **player** generate content at runtime — text, images,\nvideo, speech, music, 3D — through the game backend. The title's publisher\ndefines **features** (each with a stable `FeatureKey` such as `\"npc-chat\"`,\n`\"voxel-world\"`, `\"skin-gen\"`) in the dashboard or through the backend\ntitle-config MCP (`save_ai`). A feature fixes the modality, the model, the\nsystem prompt, the limits and the in-game price for the player. The game only\nnames the feature and sends the player's input.\n\nIt is **server-authoritative and publisher-paid**:\n\n- The client **never** picks a model, a prompt or a price. There is no such\n field on the wire, and there must not be: generation runs on the publisher's\n credits, so a client that chose the model would choose the publisher's bill.\n- The provider cost is charged to the **publisher's** credits. On top of that\n the **player** may pay in-game resources (the feature's `PriceOptions`) —\n returned on the generation as `Charge` and applied to the local cache by the\n SDK.\n- Failures after a charge are refunded in full by the server\n (`PlayerRefunded: true` on the generation).\n\nThis skill is for **using** `client.ai`. Creating or changing features is\npublisher configuration (dashboard / `save_ai`); the client cannot do it.\n\n## Key data entities\n\n1. **`AIPublicDefinitions`** (per title) — the sanitized feature list from\n `getDefinitions()`: for each `FeatureKey`, its `Modality`, `Limits`,\n `PriceOptions`, what the client may send\n (`AllowImages`, `MaxInputImages`, `MaxInputImageBytes`, `AllowedImageTypes`,\n `MaxImages`, `AllowEdit`, `AllowReferenceImage`, `MaxPromptChars`,\n `LockBehavior`, `HistoryMessages`, `MaxTokens`) and UI defaults. No prompt,\n no model. Disabled features are simply absent. Cached as config section\n `\"AI\"` — it is **not** part of the title config, you must call\n `getDefinitions()`.\n2. **`AIGenerationView`** — one generation as the player sees it:\n `GenerationID`, `FeatureKey`, `Modality`, `Status`\n (`\"pending\" | \"running\" | \"completed\" | \"failed\"`), `Progress` (0..100, async\n jobs), `OutputText`, `Assets[]` (`{ Url, ContentType, Variant }`),\n `Error { Code, Message }`, `Charge`, `PlayerRefunded`, `CreatedAt`,\n `UpdatedAt`.\n3. **No player-state slot.** Limit counters live on the server; history is\n `listGenerations()` / `getGeneration()`.\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 ai = client.ai; // the AIService\nconst defs = await ai.getDefinitions();\n```\n\nEvery method needs a session; without one it returns\n`{ ok: false, reason: \"unauthorized\" }` — it never throws.\n\n## Methods\n\nAll return `Promise<OperationResult<T>>`. The `options` argument of every\ngenerate method is `{ relatedEntityID?, selectedOptionID? }` (see\nIdempotency).\n\n| Method | Purpose | `data` |\n| --------------------------------------------- | --------------------------------------------------------------- | ---------------------- |\n| `getDefinitions()` | The title's features (sanitized). | `AIPublicDefinitions` |\n| `generateText(featureKey, input, options?)` | Submit a text job → `pending`. | `AIGenerationView` |\n| `getText(generationID)` | Poll a text job. | `AIGenerationView` |\n| `generateImage(featureKey, input, options?)` | Submit an image job → `pending`. | `AIGenerationView` |\n| `editImage(featureKey, input, options?)` | Submit an image edit (feature with `AllowEdit`) → `pending`. | `AIGenerationView` |\n| `getImage(generationID)` | Poll an image / edit job. | `AIGenerationView` |\n| `generateVideo(featureKey, input, options?)` | Submit a video job → `running`. | `AIGenerationView` |\n| `getVideo(generationID)` | Poll a video job. | `AIGenerationView` |\n| `generateAudio(featureKey, input, options?)` | Submit a speech (TTS) job → `pending`. | `AIGenerationView` |\n| `getAudio(generationID)` | Poll a speech job. | `AIGenerationView` |\n| `generateMusic(featureKey, input, options?)` | Submit a music job → `pending`. | `AIGenerationView` |\n| `getMusic(generationID)` | Poll a music job. | `AIGenerationView` |\n| `generateThreeD(featureKey, input, options?)` | Submit a 3D job → `running`. | `AIGenerationView` |\n| `getThreeD(generationID)` | Poll a 3D job. | `AIGenerationView` |\n| `generateFromTemplate(featureKey, files?, options?)` | A feature with `PlatformTemplateId`: run the platform template or trend with the player's pictures. | `AIGenerationView` |\n| `waitForGeneration(view or id, options?)` | Poll the job with its own action until it is final. | `AIGenerationView` |\n| `listGenerations(query?)` | History page (`{ Modality, FeatureKey, Status, Skip, Limit }`). | `AIGenerationListView` |\n| `getGeneration(generationID)` | One generation (history). | `AIGenerationView` |\n| `isTerminal(status)` | `true` once polling can stop. | `boolean` |\n\n**Every generation is a job** (since 13.09.2026). A generate call only submits\nit and answers `pending` (text, image, edit, speech, music — queued on the\nserver) or `running` (video, 3D — submitted to the provider); the server never\nwaits for the model inside the request. Get the result with\n`waitForGeneration(view)` — it polls the right action until the status is\nfinal and returns the final view.\n\n### Two different kinds of failure — handle both\n\n```ts\nimport { parseAIErrorCode, AIErrorCode } from \"@idosgames/core\";\n\nconst res = await client.ai.generateImage(\"skin-gen\", { Prompt: text });\n\nif (!res.ok) {\n // 1. The REQUEST was refused (nothing generated, nothing charged) — or a\n // transport problem. Server refusals arrive as \"AI_CODE: safe message\".\n const code = parseAIErrorCode(res.error);\n if (code === AIErrorCode.LimitReached)\n return showToast(\"Come back tomorrow!\");\n if (code === AIErrorCode.Unavailable)\n return showToast(\"Not available right now.\");\n if (res.reason === \"connection\") return offerRetry(); // see Idempotency\n return showToast(res.error);\n}\n\nconst done = await client.ai.waitForGeneration(res.data);\nif (!done.ok) return showToast(\"Still working — check back later.\"); // timed out / aborted; the job keeps running\n\nif (done.data.Status === \"failed\") {\n // 2. The generation RAN and failed. The calls succeeded; PlayerRefunded says\n // the player got the price back. Show Error.Message, branch on Error.Code.\n return showToast(done.data.Error?.Message ?? \"Generation failed.\");\n}\n\nshowImage(done.data.Assets?.[0]?.Url);\n```\n\n`reason` is one of `\"client\"` (missing `featureKey` / `generationID`),\n`\"unauthorized\"`, `\"throttled\"`, `\"connection\"`, `\"validation\"`, `\"server\"`.\n\n## Recipes\n\n### AI NPC dialogue (text with history)\n\n```ts\nconst history: AIChatMessage[] = [];\n\nasync function say(playerLine: string) {\n const res = await client.ai.generateText(\"npc-chat\", {\n Prompt: playerLine,\n Messages: history, // earlier turns; the server keeps the feature's HistoryMessages last ones\n });\n if (!res.ok) return null;\n const done = await client.ai.waitForGeneration(res.data, {\n intervalMs: 1000,\n });\n if (!done.ok || done.data.Status !== \"completed\") return null;\n\n history.push({ Role: \"user\", Content: playerLine });\n history.push({ Role: \"assistant\", Content: done.data.OutputText ?? \"\" });\n return done.data.OutputText;\n}\n```\n\nThe NPC's persona is the feature's system prompt on the server — don't put it\nin `Prompt`. A reply takes a few seconds: show a \"thinking…\" state and disable\nthe send button. `intervalMs: 1000` shows a short reply quickly; the wait slows\ndown on its own for a long answer.\n\n### Picture in, text out — build a voxel world from a picture\n\nFor a Text feature with `AllowImages`, attach images to the prompt. Check the\nfeature's constraints before uploading so the player gets an instant message\ninstead of a round trip:\n\n```ts\nconst feature = defs.ok ? defs.data.Features?.[\"voxel-world\"] : undefined;\n\nasync function fileToInput(file: File): Promise<AIInputImage | string> {\n if (!feature?.AllowImages) return \"This feature does not take pictures.\";\n const types = feature.AllowedImageTypes ?? [\n \"image/png\",\n \"image/jpeg\",\n \"image/webp\",\n ];\n if (!types.includes(file.type)) return \"Use a PNG, JPEG or WebP picture.\";\n if (feature.MaxInputImageBytes && file.size > feature.MaxInputImageBytes)\n return \"The picture is too large.\";\n const dataUrl = await new Promise<string>((resolve, reject) => {\n const r = new FileReader();\n r.onload = () => resolve(String(r.result));\n r.onerror = () => reject(r.error);\n r.readAsDataURL(file);\n });\n return { Base64: dataUrl, ContentType: file.type }; // a data-URL is accepted as is\n}\n\nconst image = await fileToInput(file);\nif (typeof image === \"string\") return showToast(image);\n\nconst res = await client.ai.generateText(\"voxel-world\", {\n Prompt: \"Turn this picture into a voxel world. Answer with JSON only.\",\n Images: [image],\n});\nif (!res.ok) return showToast(res.error);\n\n// A whole world is a long answer (minutes): wait with progress, stop when the screen closes.\nconst done = await client.ai.waitForGeneration(res.data, {\n signal: screenAbort.signal,\n});\n\nconst world = parseWorldJson(done.ok ? done.data.OutputText : null); // validate it — it is model output\n```\n\nThe expected output format (\"JSON with blocks: [...]\") belongs in the feature's\nsystem prompt, so every player gets the same contract. Always validate the\nparsed structure: it is model output, not trusted data.\n\n### Generate an image, then edit it\n\n```ts\nconst img = await client.ai.generateImage(\"skin-gen\", {\n Prompt: \"a red dragon armour skin\",\n Size: \"1024x1024\", // ignored if the feature has LockBehavior\n});\nif (!img.ok) return;\nconst made = await client.ai.waitForGeneration(img.data);\nif (!made.ok || made.data.Status !== \"completed\") return;\nconst url = made.data.Assets?.[0]?.Url;\n\n// Feed a generated asset back in by URL — only assets THIS title generated are accepted.\nconst edit = await client.ai.editImage(\"skin-gen\", {\n Prompt: \"make it blue\",\n Images: [{ AssetUrl: url }],\n});\nconst edited = edit.ok ? await client.ai.waitForGeneration(edit.data) : edit;\n```\n\n### Waiting for a job — `waitForGeneration`\n\nNothing polls on its own (platform convention, as with chat): each poll is an\nAPI call billed to the publisher, so wait only while the player needs the\nresult.\n\n```ts\nconst job = await client.ai.generateVideo(\"trailer\", {\n Prompt: \"a dragon over a castle\",\n Seconds: 8,\n});\nif (job.ok) {\n const done = await client.ai.waitForGeneration(job.data, {\n signal: screenAbort.signal, // stop when the screen closes\n onUpdate: (v) => renderProgress(v.Progress), // 0..100 when the provider reports it\n });\n if (done.ok && done.data.Status === \"completed\")\n playVideo(done.data.Assets?.[0]?.Url);\n}\n```\n\n- Options: `intervalMs` (first pause, default 2000; grows ×1.5 up to\n `maxIntervalMs`, default 5000), `timeoutMs` (default 30 minutes), `signal`,\n `onUpdate`. A dropped connection does not end the wait; a timeout or the\n signal ends it with `reason: \"client\"` — the job keeps running on the server.\n- An abandoned job is not lost: save its `GenerationID` and resume later with\n `waitForGeneration(id)` (it reads the generation once to learn its modality),\n or find it in `listGenerations()`.\n- Polling by hand: use the action of the modality (`getText`, `getImage`,\n `getAudio`, `getMusic`, `getVideo`, `getThreeD`) — only it moves a provider\n job forward. `isTerminal` treats anything other than `pending` / `running`\n as final, so a status added later never makes a loop spin forever.\n- 3D `Assets` may hold several files — pick by `Variant` / `ContentType`: the\n model's `Variant` is its extension (`glb`, `fbx`, `splat`…), images are\n `thumbnail`, `front` / `left` / `back` / `right`, `uv`, `mask`. `rig-check`\n has no file at all: its answer is `OutputText` JSON.\n- Chains (`rig` → `retarget`, a model → `texture` / `convert`…) pass the earlier\n result as `SourceGenerationId` — the player's own finished generation, never\n a raw vendor id or a link. The feature, not the player, picks the operation.\n\n### Speech and music\n\n```ts\nconst voice = await client.ai.generateAudio(\"npc-voice\", {\n Text: \"Welcome, traveller!\",\n});\nconst spoken = voice.ok ? await client.ai.waitForGeneration(voice.data) : voice;\nif (spoken.ok && spoken.data.Status === \"completed\")\n new Audio(spoken.data.Assets?.[0]?.Url).play();\n\nconst track = await client.ai.generateMusic(\"level-music\", {\n Prompt: \"calm lofi for a farm level\",\n});\nconst music = track.ok ? await client.ai.waitForGeneration(track.data) : track;\n```\n\n### Idempotency and retries\n\nEach generate call sends a `RelatedEntityID`. Without `options` the SDK mints\na fresh one per call, so **two calls are two paid generations**. A retry with\nthe **same** key returns the original generation and is never charged twice.\n\nGenerate calls use a long timeout and **no automatic transport retry** (a\nresend could reach the server while the first attempt is still running). A\nnetwork failure therefore surfaces as `reason: \"connection\"`. To retry\nsafely, own the key:\n\n```ts\nconst key = crypto.randomUUID();\nlet res = await client.ai.generateImage(\"skin-gen\", input, {\n relatedEntityID: key,\n});\nif (!res.ok && res.reason === \"connection\")\n res = await client.ai.generateImage(\"skin-gen\", input, {\n relatedEntityID: key,\n }); // same generation\n```\n\nA key must be 1–128 characters of `A–Z a–z 0–9 _ -` (a UUID fits). Anything\nelse fails locally with `reason: \"client\"`: the server would strip the other\ncharacters and cut at 128, and two different keys could silently become one\ngeneration. Don't add the feature or player to the key — the server already\nscopes keys by title and player.\n\n### Pay with a specific option\n\n```ts\nconst feature = defs.ok ? defs.data.Features?.[\"skin-gen\"] : undefined;\n// feature.PriceOptions: Record<OptionID, PriceOption> — empty/absent = free for the player.\nawait client.ai.generateImage(\"skin-gen\", input, { selectedOptionID: \"gems\" });\n```\n\nOmit `selectedOptionID` to pay with the first available option. Filter options\nby platform with `client.checkout` before showing them, like any price.\n\n### Pay with a store purchase (IAP)\n\nAn option may be a store purchase (a `Purchase` entry): **one purchase pays for\nexactly one generation.** Buy the consumable in the native store, keep the\ntransaction PENDING, and send its receipt:\n\n```ts\nconst res = await client.ai.generateImage(\"skin-gen\", input, {\n selectedOptionID: \"store\",\n payment: { Store: \"GooglePlay\", Receipt: receipt },\n});\nconst done = res.ok ? await client.ai.waitForGeneration(res.data) : res;\nif (done.ok && done.data.PaymentSettled === true) finishStoreTransaction(); // consume now\n```\n\n- `PaymentSettled: true` — the generation is ready, the purchase is counted:\n finish (consume) the store transaction.\n- `PaymentSettled: false` on a job still in progress (every generation until it\n completes) — keep the transaction pending and finish it when a poll reports `true`.\n- `PaymentSettled: false` on a **failed** generation — keep the transaction and\n retry with the SAME receipt and a NEW `relatedEntityID`: the retry is free.\n- Re-sending a receipt that already paid returns that generation — it never\n pays for a second one. A publisher out of credits refuses BEFORE the receipt\n is taken, so the purchase is not burnt.\n\n### History\n\n```ts\nconst page = await client.ai.listGenerations({\n Modality: \"Image\",\n Status: \"completed\",\n Limit: 20,\n});\nconst one = await client.ai.getGeneration(generationID);\n```\n\nHistory reads never change the cache and emit no events.\n\n## Events\n\nSubscribe with `client.on(...)` (returns an unsubscribe fn):\n\n- `ai:definitionsLoaded` → `AIPublicDefinitions`\n- `ai:generationUpdated` → `AIGenerationView` — every generate and poll response\n- `ai:generationCompleted` → `AIGenerationView` — a generate/poll came back `completed`\n- `ai:generationFailed` → `AIGenerationView` — a generate/poll came back `failed`\n\nApplying a `Charge` also fires the usual resource events\n(`user:virtualCurrencyUpdated`, `user:inventoryUpdated`,\n`user:eventTokenUpdated`, `user:anyUpdated`).\n\n### A trend or template of the platform AI studio\n\nA feature with `PlatformTemplateId` (a template or trend from idosgames.com/ai) takes only the template's pictures\nand variables: the prompt, the tier form (aspect ratio, length…) and the price come from the server, and the prompt\nnever reaches the client. The plain generate action of such a feature fails with `AI_WRONG_ACTION`.\n\n```ts\nconst defs = await client.ai.getDefinitions();\nconst trend = defs.ok ? defs.data.Features?.[\"dance-trend\"] : undefined;\nif (trend?.PlatformTemplateId) {\n const job = await client.ai.generateFromTemplate(\n \"dance-trend\",\n { face: { Base64: photoBase64 } }, // keys = the template's inputs\n { variables: { tempo: \"fast\" } },\n );\n const done = job.ok ? await client.ai.waitForGeneration(job.data) : job;\n}\n```\n\nRefusals of the template itself: `TEMPLATE_INPUT_REQUIRED: <key>` (a required picture is missing),\n`TEMPLATE_VALUE_REQUIRED: <name>`, `TEMPLATE_BAD_VALUE: <name>` (a choice outside its list),\n`TEMPLATE_UNSUPPORTED` (an app cannot run this template yet); a template taken off the site answers `AI_UNAVAILABLE`.\n\n## Error codes\n\n`AIErrorCode` constants; a refusal's `error` reads `\"CODE: message\"`\n(`parseAIErrorCode`), a failed generation carries `Error.Code`.\n\n| Code | Meaning | What to show |\n| ---------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------ |\n| `AI_DISABLED` | AI is off for the title | hide AI UI |\n| `AI_FEATURE_NOT_FOUND` / `AI_FEATURE_DISABLED` | no such / disabled feature | hide the entry point; refresh `getDefinitions()` |\n| `AI_WRONG_ACTION` | action ≠ feature modality | a bug in the game: fix the call |\n| `AI_NOT_AVAILABLE_FOR_PLAYER` | the feature's audience gate excludes this player | hide the entry point |\n| `AI_FEATURE_CLOSED` | outside the feature's schedule | \"not available now\" |\n| `AI_LIMIT_REACHED` / `AI_COOLDOWN` | the feature's `Limits` | \"come back later\" |\n| `AI_BAD_INPUT` | too long, too many / too large / wrong images, a foreign `AssetUrl` | the message |\n| `AI_PROMPT_REJECTED` | blocked terms | ask to rephrase |\n| `AI_UNAVAILABLE` | the publisher's side (credits, plan, caps) — reason deliberately hidden | a neutral \"not available right now\" |\n| `AI_GENERATION_FAILED` | the provider failed; everything charged is returned | \"try again\" |\n| `AI_GENERATION_NOT_FOUND` | unknown `GenerationID` | drop the stored id |\n\nTreat any other `AI_…` code generically — newer backends may add codes.\n\n## Gotchas\n\n- **A generate response is not the result.** It is `pending` / `running`; wait\n with `waitForGeneration`, then check the final `Status` — a failed generation\n is a successful call (`result.ok` ≠ \"generated\").\n- **One generate per action at a time.** A second `generateImage` while one is\n in flight is rejected with `reason: \"throttled\"` (the server serializes\n them per player and action anyway). Disable the button while waiting. Polls\n of _different_ jobs are not throttled against each other.\n- **The Charge is handled for you.** It is applied once, from the generate\n response. A generation with `PlayerRefunded: true` (failed after charging;\n the server returned the price) is never applied — its `Charge` still shows\n what was taken — and if this session had already applied it (every job is\n charged at submit), the SDK re-reads the inventory before emitting the\n event. A failed generation WITHOUT `PlayerRefunded` keeps its charge. Don't\n apply `Charge` yourself.\n- **Behaviour overrides are optional and may be ignored.** Size, quality,\n voice, image count… apply only when the feature has no `LockBehavior`;\n `MaxTokens` can only _lower_ the feature's value; `MaxPromptChars` counts the\n history too.\n- **Input images are checked by file signature**, not by the declared\n `ContentType`; `AssetUrl` must point to an asset this title generated.\n- **Don't cache `Assets[].Url` forever as the only copy** if the player owns\n the result — store the `GenerationID` and re-read it when needed.\n- **Unknown modality on an old SDK** falls back to `\"Text\"` when parsing\n definitions; the server still enforces the real one (`AI_WRONG_ACTION`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every request/response\nfield, the feature fields and their defaults (no platform caps — only the\nmodel's own limits), the server-side order of checks,\nand how charges, refunds and async jobs behave.\n",
|
|
5
5
|
"references": [
|
|
6
6
|
{
|
|
7
7
|
"path": "data-model.md",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "idosgames-compose-modules",
|
|
3
3
|
"description": "Put an iDosGames app together from the base and modules — the systems (shop, heroes, leaderboards, quests, chests, multiplayer…) as lobby tabs, one or several games behind \"Play\", progress shared across them. Use this whenever a developer wants to COMBINE or MERGE modules into a single title, add or remove a system, add a second game, switch between the lobby and game modes, share currency/inventory across modules, or asks how the lobby, \"Play\", ctx.modes, activeOnly panels, the shared-UI roles (sharedUi, shouldDraw, the \"modes\" role), typed cross-module events (defineTopic/shape) or host-level shared state work. Builds on idosgames-getting-started (scaffolding) and idosgames-module-contract (a single module).",
|
|
4
|
-
"content": "---\nname: idosgames-compose-modules\ndescription: >-\n Put an iDosGames app together from the base and modules — the systems (shop, heroes, leaderboards,\n quests, chests, multiplayer…) as lobby tabs, one or several games behind \"Play\", progress shared across them. Use this\n whenever a developer wants to COMBINE or MERGE modules into a single title, add or remove a system,\n add a second game, switch between the lobby and game modes, share currency/inventory across modules,\n or asks how the lobby, \"Play\", ctx.modes, activeOnly panels, the shared-UI roles (sharedUi,\n shouldDraw, the \"modes\" role), typed cross-module events (defineTopic/shape) or host-level shared\n state work. Builds on idosgames-getting-started (scaffolding) and idosgames-module-contract (a single\n module).\n---\n\n# Putting an app together: the base + modules\n\nEvery project is **the base + modules**. The base is sign-in and the lobby and comes from the SDK\n(`@idosgames/app-shell`: `createBaseModule`, `DefaultLoginScreen`, `DefaultPlayAccessScreen`); the project\nconfigures it in `src/app.config.ts` (where the player lands, the tab order, guest sign-in), `src/ui.config.ts`\n(the look) and `src/main.tsx`. Everything else is a module in `src/modules/<id>/`:\n\n- **systems** (`type: feature`) — one per player-facing module of the engine: store, premium (VIP),\n character, inventory, lootboxes, boosts, leaderboard, rewards (daily), quests, collections, craft,\n marketplace, deals, events, coop-events, season, friends, chat, invite, pvp, multiplayer, profile,\n tutorials, workshop, ai, mailbox. Each registers its screen as a **feature** → a tab of the lobby.\n The base installs only six of them into every new project (`inBase: true`: profile, inventory,\n invite, friends, quests, mailbox); every other system is installed only when the app needs it — a game\n brings the ones it lists in `uses`, and you install any other one as soon as the task needs what\n it `provides` (InstallModule / `get_module`) — the user does not have to name the module; reuse it\n instead of writing that feature again. Never install one the task does not need.\n- **games** (`type: template`) — board-game, idle-rpg, voxelcraft, mini-game, or the game's own. Each\n registers a **route** → a \"Play\" button in the lobby. A game that plugs into a system lists it in\n `uses` in its module.meta.json (`ctx.multiplayer` → `\"multiplayer\"`, `ctx.content` → `\"workshop\"`;\n `npm run check` enforces those two) — install those together with the game.\n\n## Register modules\n\n```ts\n// src/modules.ts — the platform regenerates it; by hand it is imports + the array\nexport const modules: Module[] = [\n storeModule, // systems: lobby tabs\n characterModule,\n leaderboardModule,\n boardGameModule, // games: \"Play\"\n idleRpgModule,\n];\n```\n\n`src/main.tsx` mounts `[createBaseModule(appConfig, …), ...modules]`. After sign-in the player lands in the game that\nlives inside the lobby, or in the lobby when the games take the whole screen (`src/app.config.ts`\n`start`: `\"game\"` — the default, `\"lobby\"`, or a game's mode id). \"Play\" switches the host to the\ngame's mode: only the active mode's scene is mounted and ticking; the rest are suspended (their RAF\nstops). This is why two different engines (Three + Phaser) live in one project — they never render\nat the same time. Two kinds of game (owner's decision 03.10.2026 — chosen per game, never for all):\n\n- `ctx.registerRoute({ …, inLobby: true })` — the game sits INSIDE the lobby (idle-rpg): the base\n keeps the balances on top and the tabs at the bottom (host panel slots `header`/`footer` — the\n game is laid out between them, it does nothing for it); \"Play\" is the game, any other tab opens\n that screen, the app starts in it. A game with its own bar to the systems hides it when\n `ctx.sharedUi.shouldDraw(\"menu\")` is false (idle-rpg does).\n- without it (the default) — the game takes the whole screen (voxelcraft, board-game, mini-game):\n \"Play\" in the lobby launches it, over it the base draws only a small \"To the lobby\" button.\n A game with its own way back (a pause menu with \"To the lobby\" → `ctx.lobby.open()`, and a pause\n button on touch screens) says `ownExit: true` in its route — then the base draws nothing over it\n (voxelcraft).\n\n- A system tab is hidden while the title has nothing for it (no store offers, no heroes…): the module\n registers with `available: false` and calls `ctx.features.setAvailable(id, ok, reason)`.\n- `ctx.modes.list()` / `current()` / `subscribe()` — every mode (id, label, icon, module, module type);\n the lobby builds \"Play\" from the `game` ones. Switch with `ctx.navigate(id)` (`\"lobby\"` is the base).\n- Open another system's screen by id: `ctx.features.open(\"store\")` — from a game too (the host shows\n it in a window while the lobby is off screen). Never import another module.\n\n## Share progress across modes (the merge)\n\nShared state lives in the ONE SDK client, not in any module. Every module reads/writes the same\n`client` (currency, inventory, characters), so progress carries across modes automatically:\n\n- Gold earned idle in the RPG mode is spendable in the Board mode and in the shop — same\n `client.currency`, same cache. No cross-module plumbing needed for durable state.\n- For live cross-module signals (not durable state), use `ctx.events` with a **typed topic** — see\n \"Signals between modules\" below.\n- Modules never import each other's files. The client and the event bus are the seams\n between them — which is what keeps a module reusable in another game. Code several of THIS game's\n modules need (shared types, the game's UI kit, helpers) goes to `src/shared/`, which never imports a\n module (see idosgames-project-structure).\n\n## Shared chrome: roles, and the base takes them\n\nBalances, the crypto wallet, the status line, \"Log out\" / player ID, the menu of features and the\nswitch between modes are **shared-UI roles** (`currency-bar | wallet | status | account | menu |\nmodes`). A module that draws one for EVERY mode declares it statically on the module object —\nthe base does:\n\n```ts\ndefineModule({\n id: \"base\",\n meta: { name: \"Base\", type: \"app\", engine: \"dom\" },\n sharedUi: {\n provides: [\"currency-bar\", \"wallet\", \"account\", \"menu\", \"modes\"],\n },\n setup(ctx) {\n /* the lobby: balances, wallet, profile, tabs, \"Play\" */\n },\n});\n```\n\nThe host resolves role owners from these declarations BEFORE any `setup()` — the first provider wins,\na conflict is warned — and every module asks `ctx.sharedUi.shouldDraw(role)`. A game draws its own\ncopy only while nobody took the role. So:\n\n- Next to the base a game hides its own balances and wallet by itself. Do NOT edit a catalog game to\n remove them: it does that itself, and an edit marks it customized (no more catalog updates).\n- `status` — the line of a game's own actions (\"Rolling…\") — stays with each game.\n- A game installed without a base stays complete — nobody owns a role, it draws everything, and the\n host draws its own nav of routes, ☰ and \"Log out\" (those are the host-drawn roles `modes`, `menu`,\n `account`).\n- A new module that needs, say, the balance bar but does not draw it declares\n `sharedUi: { requires: [\"currency-bar\"] }`; with no provider installed the host warns.\n\n**Layout is the host's job.** A game's overlay fills the screen under the base's small button; a\nscene's own DOM HUD can keep clear of host chrome with the CSS variables on the host root —\n`top: calc(var(--idos-safe-top, 0px) + 8px)`, `bottom: calc(var(--idos-safe-bottom, 0px) + 8px)`\n(voxelcraft's hotbar does this). Both are 0 when nothing is pinned. `hud`-slot panels are wrapped in\n`display: contents`, so a panel can stretch (`flex: 1`) and decide its own pointer-events: let clicks\nthrough to the game, take them only on controls.\n\n## Signals between modules (typed topics)\n\nA topic is a token passed by value: the name carries the major version, the payload is a flat JSON\nshape — one artifact that gives the TS type, the runtime check and the catalog entry.\n\n```ts\n// idle-rpg/events.ts — the EMITTER owns the topic\nimport { defineTopic, shape } from \"@idosgames/module-sdk\";\nexport const characterUpgraded = defineTopic(\n \"idle-rpg:character-upgraded@1\",\n shape({ characterId: \"string\", level: \"number\" }),\n);\n// setup(): hand the panel a callback that does\n// ctx.events.emit(characterUpgraded, { characterId, level });\n```\n\n```ts\n// board-game/events.ts — the LISTENER keeps its OWN copy (copied from the catalog), never an import.\n// Optional: idle-rpg may not be installed. Only the fields this module reads.\nexport const idleCharacterUpgraded = defineTopic(\n \"idle-rpg:character-upgraded@1\",\n shape({ level: \"number\" }),\n);\n// setup() — NOT a panel effect (activeOnly panels unmount with their mode and miss events):\nctx.events.on(idleCharacterUpgraded, ({ level }) =>\n news.push(`Idle RPG hero reached Lv ${level}`),\n);\n```\n\nDeclare both sides in `module.meta.json` — the catalog and the agent read this, not your code:\n\n```json\n\"events\": {\n \"emits\": [{ \"topic\": \"idle-rpg:character-upgraded@1\", \"when\": \"a hero levelled up\",\n \"payload\": { \"characterId\": \"string\", \"level\": \"number\" } }],\n \"listens\": [{ \"topic\": \"idle-rpg:character-upgraded@1\", \"why\": \"news chip in the board HUD\" }]\n}\n```\n\nRules:\n\n- Name `<your-module-id>:<event>@<major>`. Emit only into your own namespace — the host drops an\n emit into another module's namespace with `console.error`; `host:` is reserved. The only topics\n you import are `hostTopics` from `@idosgames/module-sdk` (`hostTopics.modeChanged` =\n `host:mode-changed@1 { from?: string; to: string }`, sent on every mode switch).\n- Shape leaves: `\"string\" | \"number\" | \"boolean\"`, with `\"[]\"` and/or a trailing `\"?\"`; nested\n objects allowed. Payloads are plain JSON — no functions, class instances or engine objects.\n- An incompatible change is a NEW topic `@2` (send both during the transition); within a major, only\n add fields. A listener whose fields are missing is skipped (one `console.warn`), never crashed.\n- An event is a signal, not state, and is never replayed — \"what exists now\" is SDK data or\n `ctx.sharedUi`. No request/response between modules: if you need an answer, it is data.\n- A throwing handler does not stop other listeners or reach the emitter; the host removes every\n subscription on logout, so re-login does not double handlers.\n- In the AI editor's preview, `gameState` returns the last 50 events, who listens to what, and hints\n like \"board-game listens to x@1 but only x@2 is emitted\".\n- A game's OWN modules may keep shared topic tokens in `src/shared/events/` and import them on both\n sides — still declare them in each `module.meta.json`.\n\n## Reference app\n\nThe base + the systems + `board-game` + `idle-rpg`:\n\n- sign-in → straight into the `inLobby` game (else the first game): balances, wallet and profile on\n top, the systems as tabs; \"Play\" lists the two games;\n- \"Play\" → one engine scene mounted at a time; the lobby's tabs stay around an `inLobby` game, a\n full-screen game gets the button back;\n- the games hide their own balances and wallet (the lobby has them); their status lines stay theirs;\n- currency changed in one mode is immediately visible in the others and in the lobby; levelling a hero\n in Idle RPG shows a news chip on the board (`idle-rpg:character-upgraded@1`).\n\nTo pull the modules, use `get_module {id}` for each (MCP) and register them as above. Adjust layouts\nper module (a full-bleed overlay UI vs a docked side panel) — see each module's RootPanel.\n",
|
|
4
|
+
"content": "---\nname: idosgames-compose-modules\ndescription: >-\n Put an iDosGames app together from the base and modules — the systems (shop, heroes, leaderboards,\n quests, chests, multiplayer…) as lobby tabs, one or several games behind \"Play\", progress shared across them. Use this\n whenever a developer wants to COMBINE or MERGE modules into a single title, add or remove a system,\n add a second game, switch between the lobby and game modes, share currency/inventory across modules,\n or asks how the lobby, \"Play\", ctx.modes, activeOnly panels, the shared-UI roles (sharedUi,\n shouldDraw, the \"modes\" role), typed cross-module events (defineTopic/shape) or host-level shared\n state work. Builds on idosgames-getting-started (scaffolding) and idosgames-module-contract (a single\n module).\n---\n\n# Putting an app together: the base + modules\n\nEvery project is **the base + modules**. The base is sign-in and the lobby and comes from the SDK\n(`@idosgames/app-shell`: `createBaseModule`, `DefaultLoginScreen`, `DefaultPlayAccessScreen`); the project\nconfigures it in `src/app.config.ts` (where the player lands, the tab order, guest sign-in), `src/ui.config.ts`\n(the look) and `src/main.tsx`. Everything else is a module in `src/modules/<id>/`:\n\n- **systems** (`type: feature`) — one per player-facing module of the engine: store, premium (VIP),\n character, inventory, lootboxes, boosts, leaderboard, rewards (daily), quests, collections, craft,\n marketplace, deals, events, coop-events, season, friends, chat, invite, pvp, multiplayer, profile,\n tutorials, workshop, ai, mailbox. Each registers its screen as a **feature** → a tab of the lobby.\n The base installs only six of them into every new project (`inBase: true`: profile, inventory,\n invite, friends, quests, mailbox); every other system is installed only when the app needs it — a game\n brings the ones it lists in `uses`, and you install any other one as soon as the task needs what\n it `provides` (InstallModule / `get_module`) — the user does not have to name the module; reuse it\n instead of writing that feature again. Never install one the task does not need.\n- **games** (`type: template`) — board-game, idle-rpg, voxelcraft, kit-game, or the game's own. Each\n registers a **route** → a \"Play\" button in the lobby. A game that plugs into a system lists it in\n `uses` in its module.meta.json (`ctx.multiplayer` → `\"multiplayer\"`, `ctx.content` → `\"workshop\"`;\n `npm run check` enforces those two) — install those together with the game.\n\n## Register modules\n\n```ts\n// src/modules.ts — the platform regenerates it; by hand it is imports + the array\nexport const modules: Module[] = [\n storeModule, // systems: lobby tabs\n characterModule,\n leaderboardModule,\n boardGameModule, // games: \"Play\"\n idleRpgModule,\n];\n```\n\n`src/main.tsx` mounts `[createBaseModule(appConfig, …), ...modules]`. After sign-in the player lands in the game that\nlives inside the lobby, or in the lobby when the games take the whole screen (`src/app.config.ts`\n`start`: `\"game\"` — the default, `\"lobby\"`, or a game's mode id). \"Play\" switches the host to the\ngame's mode: only the active mode's scene is mounted and ticking; the rest are suspended (their RAF\nstops). This is why two different engines (Three + Phaser) live in one project — they never render\nat the same time. Two kinds of game (owner's decision 03.10.2026 — chosen per game, never for all):\n\n- `ctx.registerRoute({ …, inLobby: true })` — the game sits INSIDE the lobby (idle-rpg): the base\n keeps the balances on top and the tabs at the bottom (host panel slots `header`/`footer` — the\n game is laid out between them, it does nothing for it); \"Play\" is the game, any other tab opens\n that screen, the app starts in it. A game with its own bar to the systems hides it when\n `ctx.sharedUi.shouldDraw(\"menu\")` is false (idle-rpg does).\n- without it (the default) — the game takes the whole screen (voxelcraft, kit-game):\n \"Play\" in the lobby launches it, over it the base draws only a small \"To the lobby\" button.\n A game with its own way back (a pause menu with \"To the lobby\" → `ctx.lobby.open()`, and a pause\n button on touch screens) says `ownExit: true` in its route — then the base draws nothing over it\n (voxelcraft).\n\n- A system tab is hidden while the title has nothing for it (no store offers, no heroes…): the module\n registers with `available: false` and calls `ctx.features.setAvailable(id, ok, reason)`.\n- `ctx.modes.list()` / `current()` / `subscribe()` — every mode (id, label, icon, module, module type);\n the lobby builds \"Play\" from the `game` ones. Switch with `ctx.navigate(id)` (`\"lobby\"` is the base).\n- Open another system's screen by id: `ctx.features.open(\"store\")` — from a game too (the host shows\n it in a window while the lobby is off screen). Never import another module.\n\n## Share progress across modes (the merge)\n\nShared state lives in the ONE SDK client, not in any module. Every module reads/writes the same\n`client` (currency, inventory, characters), so progress carries across modes automatically:\n\n- Gold earned idle in the RPG mode is spendable in the Board mode and in the shop — same\n `client.currency`, same cache. No cross-module plumbing needed for durable state.\n- For live cross-module signals (not durable state), use `ctx.events` with a **typed topic** — see\n \"Signals between modules\" below.\n- Modules never import each other's files. The client and the event bus are the seams\n between them — which is what keeps a module reusable in another game. Code several of THIS game's\n modules need (shared types, the game's UI kit, helpers) goes to `src/shared/`, which never imports a\n module (see idosgames-project-structure).\n\n## Shared chrome: roles, and the base takes them\n\nBalances, the crypto wallet, the status line, \"Log out\" / player ID, the menu of features and the\nswitch between modes are **shared-UI roles** (`currency-bar | wallet | status | account | menu |\nmodes`). A module that draws one for EVERY mode declares it statically on the module object —\nthe base does:\n\n```ts\ndefineModule({\n id: \"base\",\n meta: { name: \"Base\", type: \"app\", engine: \"dom\" },\n sharedUi: {\n provides: [\"currency-bar\", \"wallet\", \"account\", \"menu\", \"modes\"],\n },\n setup(ctx) {\n /* the lobby: balances, wallet, profile, tabs, \"Play\" */\n },\n});\n```\n\nThe host resolves role owners from these declarations BEFORE any `setup()` — the first provider wins,\na conflict is warned — and every module asks `ctx.sharedUi.shouldDraw(role)`. A game draws its own\ncopy only while nobody took the role. So:\n\n- Next to the base a game hides its own balances and wallet by itself. Do NOT edit a catalog game to\n remove them: it does that itself, and an edit marks it customized (no more catalog updates).\n- `status` — the line of a game's own actions (\"Rolling…\") — stays with each game.\n- A game installed without a base stays complete — nobody owns a role, it draws everything, and the\n host draws its own nav of routes, ☰ and \"Log out\" (those are the host-drawn roles `modes`, `menu`,\n `account`).\n- A new module that needs, say, the balance bar but does not draw it declares\n `sharedUi: { requires: [\"currency-bar\"] }`; with no provider installed the host warns.\n\n**Layout is the host's job.** A game's overlay fills the screen under the base's small button; a\nscene's own DOM HUD can keep clear of host chrome with the CSS variables on the host root —\n`top: calc(var(--idos-safe-top, 0px) + 8px)`, `bottom: calc(var(--idos-safe-bottom, 0px) + 8px)`\n(voxelcraft's hotbar does this). Both are 0 when nothing is pinned. `hud`-slot panels are wrapped in\n`display: contents`, so a panel can stretch (`flex: 1`) and decide its own pointer-events: let clicks\nthrough to the game, take them only on controls.\n\n## Signals between modules (typed topics)\n\nA topic is a token passed by value: the name carries the major version, the payload is a flat JSON\nshape — one artifact that gives the TS type, the runtime check and the catalog entry.\n\n```ts\n// idle-rpg/events.ts — the EMITTER owns the topic\nimport { defineTopic, shape } from \"@idosgames/module-sdk\";\nexport const characterUpgraded = defineTopic(\n \"idle-rpg:character-upgraded@1\",\n shape({ characterId: \"string\", level: \"number\" }),\n);\n// setup(): hand the panel a callback that does\n// ctx.events.emit(characterUpgraded, { characterId, level });\n```\n\n```ts\n// board-game/events.ts — the LISTENER keeps its OWN copy (copied from the catalog), never an import.\n// Optional: idle-rpg may not be installed. Only the fields this module reads.\nexport const idleCharacterUpgraded = defineTopic(\n \"idle-rpg:character-upgraded@1\",\n shape({ level: \"number\" }),\n);\n// setup() — NOT a panel effect (activeOnly panels unmount with their mode and miss events):\nctx.events.on(idleCharacterUpgraded, ({ level }) =>\n news.push(`Idle RPG hero reached Lv ${level}`),\n);\n```\n\nDeclare both sides in `module.meta.json` — the catalog and the agent read this, not your code:\n\n```json\n\"events\": {\n \"emits\": [{ \"topic\": \"idle-rpg:character-upgraded@1\", \"when\": \"a hero levelled up\",\n \"payload\": { \"characterId\": \"string\", \"level\": \"number\" } }],\n \"listens\": [{ \"topic\": \"idle-rpg:character-upgraded@1\", \"why\": \"news chip in the board HUD\" }]\n}\n```\n\nRules:\n\n- Name `<your-module-id>:<event>@<major>`. Emit only into your own namespace — the host drops an\n emit into another module's namespace with `console.error`; `host:` is reserved. The only topics\n you import are `hostTopics` from `@idosgames/module-sdk` (`hostTopics.modeChanged` =\n `host:mode-changed@1 { from?: string; to: string }`, sent on every mode switch).\n- Shape leaves: `\"string\" | \"number\" | \"boolean\"`, with `\"[]\"` and/or a trailing `\"?\"`; nested\n objects allowed. Payloads are plain JSON — no functions, class instances or engine objects.\n- An incompatible change is a NEW topic `@2` (send both during the transition); within a major, only\n add fields. A listener whose fields are missing is skipped (one `console.warn`), never crashed.\n- An event is a signal, not state, and is never replayed — \"what exists now\" is SDK data or\n `ctx.sharedUi`. No request/response between modules: if you need an answer, it is data.\n- A throwing handler does not stop other listeners or reach the emitter; the host removes every\n subscription on logout, so re-login does not double handlers.\n- In the AI editor's preview, `gameState` returns the last 50 events, who listens to what, and hints\n like \"board-game listens to x@1 but only x@2 is emitted\".\n- A game's OWN modules may keep shared topic tokens in `src/shared/events/` and import them on both\n sides — still declare them in each `module.meta.json`.\n\n## Reference app\n\nThe base + the systems + `board-game` + `idle-rpg`:\n\n- sign-in → straight into the `inLobby` game (else the first game): balances, wallet and profile on\n top, the systems as tabs; \"Play\" lists the two games;\n- \"Play\" → one engine scene mounted at a time; the lobby's tabs stay around an `inLobby` game, a\n full-screen game gets the button back;\n- the games hide their own balances and wallet (the lobby has them); their status lines stay theirs;\n- currency changed in one mode is immediately visible in the others and in the lobby; levelling a hero\n in Idle RPG shows a news chip on the board (`idle-rpg:character-upgraded@1`).\n\nTo pull the modules, use `get_module {id}` for each (MCP) and register them as above. Adjust layouts\nper module (a full-bleed overlay UI vs a docked side panel) — see each module's RootPanel.\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "idosgames-getting-started",
|
|
3
|
-
"description": "Start a new game or app on the iDosGames composable-module architecture: scaffold the base (sign-in + the lobby on the host shell) and plug in modules — the systems (shop, heroes, leaderboards…) and the games (board-game, idle-rpg, voxelcraft,
|
|
4
|
-
"content": "---\nname: idosgames-getting-started\ndescription: >-\n Start a new game or app on the iDosGames composable-module architecture: scaffold the base (sign-in\n + the lobby on the host shell) and plug in modules — the systems (shop, heroes, leaderboards…) and\n the games (board-game, idle-rpg, voxelcraft, mini-game, …). Use this whenever\n a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a\n project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or\n src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and\n idosgames-compose-modules (merging several) and idosgames-project-structure (where code goes as\n the project grows). If pulling modules over MCP, use the @idosgames/mcp\n tools get_host_scaffold / get_module / get_manifest.\n---\n\n# Getting started (iDosGames composable modules)\n\nAn iDosGames project is **one host shell + N feature modules**. The host owns everything that exists\nonce — the SDK client, login, the React root, the screen, a module registry, and a Mode Router that\nswitches between modules. A module is a library (a game or app) that plugs into the host; it never\ncreates the client, logs in, or owns the root.\n\n## Runtime packages (npm)\n\n- `@idosgames/core` — the SDK client (`createIDosGamesClient`): auth, currency, store, characters,\n blockchain, ~28 services. See the per-service skills (currency-system, store-system, …). Its\n **central cache** `client.cache` decides every read: the login brings the title config (by version)\n and the player's state (by each container's `Rev`) in ONE call; afterwards read state through\n `client.cache.ensureState([...containers])`, definitions through `getDefinitions()` (served from the\n config cache), server views through their service (cached by policy) — never re-fetch on start-up.\n The server views the game reads ride on that same login call (`ClientState.Views`): the client\n option `loginViews` — `\"auto\"` (default, what the game read last time), `\"all\"`, `\"none\"` or a list.\n- `@idosgames/module-sdk` — the module contract types (`Module`, `ModuleContext`, `EngineScene`,\n `UiPanel`). See idosgames-module-contract.\n- `@idosgames/react` — shared React glue (`IDosGamesProvider`, `useIDosGamesClient`, `useUserState`,\n `StatusProvider`, `createControllerContext`).\n- `@idosgames/app-shell` — the host runtime (`mountHost`, the Mode Router, the module registry).\n- `@idosgames/wallet` — optional wallet bridge (EVM/Solana) for on-chain deposits/withdrawals.\n\nInstall the exact versions from `get_manifest` (MCP) or the registry `index.json` `runtimePackages`.\n\n## Project shape\n\n```\nIDOS.md # the project guide every agent reads first (short, evergreen)\nAGENTS.md, CLAUDE.md # pointers to IDOS.md for external tools\ndocs/feature-history/ # one file per game system + README.md index (the project's memory)\nindex.html # mounts #app\nsrc/main.tsx # creates ONE client and calls mountHost({container, client, modules})\nsrc/modules.ts # the registry: export const modules: Module[] = [ … ] — imports + array only\nsrc/modules/{id}/ # each module's source + its module.meta.json (from get_module)\n```\n\nPlatform-created projects also carry `idos.modules.lock.json` (which modules came from the catalog,\nwith file fingerprints) — it is platform-owned; don't edit it. Where new code goes as the game grows,\nand how the project documents itself, is **idosgames-project-structure**.\n\n`src/main.tsx` (host-owned) is the only place that creates the client — but it does **not** sign in.\n`mountHost` owns sign-in: it replays a previous session (`autoLogin`) and renders the login screen\nwhen there is nothing to replay. Calling a `login*` method here skips that screen for good, taking\n\"switch account\" and wallet sign-in with it:\n\n```ts\nconst client = createIDosGamesClient({ titleID, buildKey, throttleMs: 0 });\n// Do NOT log in here — the host does it.\nmountHost({\n container: app,\n client,\n modules, // from ./modules\n renderLogin, // optional: your own screen; omitted = a plain guest-only default\n});\n```\n\nWhether a returning player is signed back in silently is the login screen's business, not the\nhost's: it calls `client.auth.setRememberSession(remember)` before a `login*` method. See the\nauthentication skill.\n\n`src/modules.ts` registers what the project composes:\n\n```ts\nimport type { Module } from \"@idosgames/module-sdk\";\nimport { boardGameModule } from \"./modules/board-game\";\nexport const modules: Module[] = [boardGameModule];\n```\n\n## Steps to scaffold\n\n1. **Base** — write the host scaffold to the project root (`get_host_scaffold`, or copy\n `templates/base`): sign-in and the lobby come from `@idosgames/app-shell` (mounted in `src/main.tsx`), `src/app.config.ts`\n (start in the lobby or a game, guest sign-in), an empty `src/modules.ts` → an empty lobby.\n2. **Bind the Title** — open `src/idos.title.ts` and set `IDOS_TITLE_ID` to the game's canonical\n Title id (and `IDOS_BUILD_KEY` if the title enforces one). This file is the project's single\n centralized identity — it is the highest-priority source and the only channel a packaged mobile\n build, iframe embed, or shared link has. On platform-created projects the platform generates it;\n on a manually scaffolded project **you fill it yourself**. Do NOT bind the title via `.env.local`\n (`VITE_IDOS_TITLE_ID`) — that is a local-dev fallback for the raw template only, and it does not\n travel with the code.\n3. **Pick modules** — `list_modules` / `search`, then `get_module {id}` for each. Write its files\n into `src/modules/{id}/`. The base's systems are the modules with `inBase: true` (profile,\n inventory, invite, friends, quests, mailbox); add the game(s) with the modules each game `uses`, and any\n other system (shop, VIP, leaderboards…) only when the app needs it.\n4. **Register** — for each module add `import { {camelCase(id)}Module } from \"./modules/{id}\"` and\n push it into the `modules` array (e.g. `board-game` → `boardGameModule`). Then add the module to\n the \"Installed modules\" block of `IDOS.md` (one line: ``- `board-game` (0.1.0) — src/modules/board-game/``)\n — on platform projects the platform maintains that block itself.\n5. **Install deps** — the union of `runtimePackages` + each module's `dependencies`. Modules bring\n their own engine (three, phaser); the host brings react/react-dom/app-shell.\n6. **Run** — sign-in → straight INTO the game, never a \"Play\" click (owner's rule 07.11.2026): a game\n whose route says `inLobby: true` (idle-rpg) sits inside the lobby, the lobby's balances and tabs\n (the systems' screens; a tab hides itself while the title has nothing for it) around it; else the\n first game on the whole screen with a small button back to the lobby. The lobby draws the balances and the wallet; games hide their own copies by\n themselves (`sharedUi` roles).\n\nTo combine multiple genres into one game, see **idosgames-compose-modules**. To write or edit a\nmodule, see **idosgames-module-contract**.\n\n## Where state lives (decide this before writing the first save)\n\nThe project is client-side code in the player's browser. `localStorage`, module fields, and React\nstate are **not storage** — nothing there survives a device change, and nothing there is trusted.\n\n1. **A dedicated module owns it?** Use that module. Currencies, inventory, quests, characters,\n leaderboards, store purchases each have a service that enforces the rules server-side.\n2. **Otherwise, per-player data → `client.userCustomData`** — buckets `Private`/`Public` are\n client-writable (settings, cosmetics), `ReadOnly`/`Internal` are server-only. Anything a player\n could cheat by editing goes in the server-only buckets. See **user-custom-data**.\n3. **Shared by all players → `client.titleCustomData`** (event state, global counters, server\n thresholds, feature toggles). Read-only for clients. See **title-custom-data**.\n4. **Writing any of the server-only data, or any rule the player must not be able to fake** →\n a CloudCode handler, called with `client.cloudCode.execute(...)`. See **cloud-code**.\n\n## Two MCP surfaces: game CODE vs. a Title's live DATA\n\nThe platform exposes **two independent MCP servers** — don't confuse them:\n\n- **`@idosgames/mcp`** (this one) serves the **CODE registry**. Use it to WRITE a game's source:\n `get_host_scaffold`, `list_modules` / `search` / `get_module`, `get_manifest`, `list_skills` /\n `get_skill`. Transport: stdio (`npx -y @idosgames/mcp`). Its registry is bundled offline; to use the\n hosted copy set `IDOSGAMES_REGISTRY_URL=https://cloud.idosgames.com/drive/registry/latest` — a **base**\n the loader appends `/index.json`, `/modules/{id}.json`, etc. to (the base itself is not fetchable on\n R2; open `.../latest/index.json` to browse the catalog). Read-only, no auth. It never reads or changes\n a live Title's data.\n- **The Title-configuration MCP** (a separate backend server) owns a **live Title's DATA**. Use it to\n read/write the Title's `TitlePublicConfiguration` (`get_<field>` / `save_<field>`, or the whole\n model) and to generate assets (`generate_image` / `generate_audio` / `generate_text` /\n `generate_three_d` / `generate_video`). Transport: HTTP JSON-RPC at\n `POST https://site.idosgames.com/api/v2/mcp`; every tool call takes a `title_id` argument.\n Authorization is **OAuth 2.1** — there is no API key and nothing to paste. Connect it as a plain\n HTTP MCP server with **no headers**: your client gets a `401`, discovers the authorization\n server, registers itself, and opens a browser where the publisher picks which Titles and which\n permissions to grant. The token lives in your client's own credential store, so committed config\n holds only the URL:\n\n ```json\n {\n \"mcpServers\": {\n \"idosgames-title\": {\n \"type\": \"http\",\n \"url\": \"https://site.idosgames.com/api/v2/mcp\"\n }\n }\n }\n ```\n\n Permissions the publisher can grant: `config:read`, `config:write`, `cloudcode:write`,\n `ai:generate`, `kanban:read`, `kanban:write`, `build:write`. A grant is scoped to the Titles ticked on the consent screen, and the publisher\n can revoke it any time from **Connected apps** in the dashboard. If a call comes back\n `SCOPE_NOT_ALLOWED` or `TITLE_NOT_ALLOWED`, the token is fine — that permission or that Title\n simply was not granted; ask the publisher to re-authorize rather than retrying.\n\n **Shipping the game itself** (permission `build:write`) also goes through this server — the\n platform hosts it on the Title's own address (`{titleid}.idos.games`) and its idosgames.com page:\n\n 1. `begin_build_upload { title_id }` → `build_id`, a one-hour `upload_url`, `asset_base`.\n 2. Build the game as static files with that base: `vite build --base=<asset_base>` (a relative\n `./` base also works — Unity WebGL builds already are). Use the LIVE title id, never `-DEV`.\n 3. Zip the CONTENTS of the output folder (`index.html` at the zip root; a single `dist/` top folder\n is accepted) and upload it: `curl -T build.zip \"<upload_url>\"` — a plain PUT, no headers.\n 4. `finish_build_upload { title_id, build_id, deploy }` — unpacks it into a new version (it shows up\n in the dashboard's version history) and returns `TestUrl` (DEV address). `deploy: true` puts it\n live for players right away; without it, check `TestUrl` and call it again with `deploy: true`.\n Still unpacking when it returns → poll `get_build`. `list_builds` shows recent versions.\n\n A game that is live but whose Title is not published yet has no idosgames.com page — the\n response says so; the publisher publishes it from the dashboard.\n\n ⚠ **The build must be the game itself.** On the Title's idosgames.com page the game may embed\n (`<iframe>`) only platform hosts — never another website, with no setting to allow one. A\n \"wrapper\" build that signs in and then frames the publisher's own site shows **\"Content\n unavailable\"** there instead of the game: build and upload the real game. Network calls\n (`fetch`/WebSocket) from the embedded game reach platform hosts plus the Title's\n `Blockchain.IframeConnectHosts` (`save_blockchain`; wallet-connection hosts are always refused).\n\n Configuring a **fresh (empty) Title** so a game can actually run against it (currencies → game\n loop → bots) is its own checklist: see **idosgames-title-bootstrap**.\n\nRule of thumb: **game CODE → `@idosgames/mcp`; a Title's live config DATA and generated ASSETS → the\nbackend `v2/mcp` server.** Scaffolding a project and configuring/populating the Title it runs as are\ntwo different jobs on two different servers — connect the one that matches the task (or both).\n",
|
|
3
|
+
"description": "Start a new game or app on the iDosGames composable-module architecture: scaffold the base (sign-in + the lobby on the host shell) and plug in modules — the systems (shop, heroes, leaderboards…) and the games (board-game, idle-rpg, voxelcraft, kit-game, …). Use this whenever a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and idosgames-compose-modules (merging several) and idosgames-project-structure (where code goes as the project grows). If pulling modules over MCP, use the @idosgames/mcp tools get_host_scaffold / get_module / get_manifest.",
|
|
4
|
+
"content": "---\nname: idosgames-getting-started\ndescription: >-\n Start a new game or app on the iDosGames composable-module architecture: scaffold the base (sign-in\n + the lobby on the host shell) and plug in modules — the systems (shop, heroes, leaderboards…) and\n the games (board-game, idle-rpg, voxelcraft, kit-game, …). Use this whenever\n a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a\n project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or\n src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and\n idosgames-compose-modules (merging several) and idosgames-project-structure (where code goes as\n the project grows). If pulling modules over MCP, use the @idosgames/mcp\n tools get_host_scaffold / get_module / get_manifest.\n---\n\n# Getting started (iDosGames composable modules)\n\nAn iDosGames project is **one host shell + N feature modules**. The host owns everything that exists\nonce — the SDK client, login, the React root, the screen, a module registry, and a Mode Router that\nswitches between modules. A module is a library (a game or app) that plugs into the host; it never\ncreates the client, logs in, or owns the root.\n\n## Runtime packages (npm)\n\n- `@idosgames/core` — the SDK client (`createIDosGamesClient`): auth, currency, store, characters,\n blockchain, ~28 services. See the per-service skills (currency-system, store-system, …). Its\n **central cache** `client.cache` decides every read: the login brings the title config (by version)\n and the player's state (by each container's `Rev`) in ONE call; afterwards read state through\n `client.cache.ensureState([...containers])`, definitions through `getDefinitions()` (served from the\n config cache), server views through their service (cached by policy) — never re-fetch on start-up.\n The server views the game reads ride on that same login call (`ClientState.Views`): the client\n option `loginViews` — `\"auto\"` (default, what the game read last time), `\"all\"`, `\"none\"` or a list.\n- `@idosgames/module-sdk` — the module contract types (`Module`, `ModuleContext`, `EngineScene`,\n `UiPanel`). See idosgames-module-contract.\n- `@idosgames/react` — shared React glue (`IDosGamesProvider`, `useIDosGamesClient`, `useUserState`,\n `StatusProvider`, `createControllerContext`).\n- `@idosgames/app-shell` — the host runtime (`mountHost`, the Mode Router, the module registry).\n- `@idosgames/wallet` — optional wallet bridge (EVM/Solana) for on-chain deposits/withdrawals.\n\nInstall the exact versions from `get_manifest` (MCP) or the registry `index.json` `runtimePackages`.\n\n## Project shape\n\n```\nIDOS.md # the project guide every agent reads first (short, evergreen)\nAGENTS.md, CLAUDE.md # pointers to IDOS.md for external tools\ndocs/feature-history/ # one file per game system + README.md index (the project's memory)\nindex.html # mounts #app\nsrc/main.tsx # creates ONE client and calls mountHost({container, client, modules})\nsrc/modules.ts # the registry: export const modules: Module[] = [ … ] — imports + array only\nsrc/modules/{id}/ # each module's source + its module.meta.json (from get_module)\n```\n\nPlatform-created projects also carry `idos.modules.lock.json` (which modules came from the catalog,\nwith file fingerprints) — it is platform-owned; don't edit it. Where new code goes as the game grows,\nand how the project documents itself, is **idosgames-project-structure**.\n\n`src/main.tsx` (host-owned) is the only place that creates the client — but it does **not** sign in.\n`mountHost` owns sign-in: it replays a previous session (`autoLogin`) and renders the login screen\nwhen there is nothing to replay. Calling a `login*` method here skips that screen for good, taking\n\"switch account\" and wallet sign-in with it:\n\n```ts\nconst client = createIDosGamesClient({ titleID, buildKey, throttleMs: 0 });\n// Do NOT log in here — the host does it.\nmountHost({\n container: app,\n client,\n modules, // from ./modules\n renderLogin, // optional: your own screen; omitted = a plain guest-only default\n});\n```\n\nWhether a returning player is signed back in silently is the login screen's business, not the\nhost's: it calls `client.auth.setRememberSession(remember)` before a `login*` method. See the\nauthentication skill.\n\n`src/modules.ts` registers what the project composes:\n\n```ts\nimport type { Module } from \"@idosgames/module-sdk\";\nimport { boardGameModule } from \"./modules/board-game\";\nexport const modules: Module[] = [boardGameModule];\n```\n\n## Steps to scaffold\n\n1. **Base** — write the host scaffold to the project root (`get_host_scaffold`, or copy\n `templates/base`): sign-in and the lobby come from `@idosgames/app-shell` (mounted in `src/main.tsx`), `src/app.config.ts`\n (start in the lobby or a game, guest sign-in), an empty `src/modules.ts` → an empty lobby.\n2. **Bind the Title** — open `src/idos.title.ts` and set `IDOS_TITLE_ID` to the game's canonical\n Title id (and `IDOS_BUILD_KEY` if the title enforces one). This file is the project's single\n centralized identity — it is the highest-priority source and the only channel a packaged mobile\n build, iframe embed, or shared link has. On platform-created projects the platform generates it;\n on a manually scaffolded project **you fill it yourself**. Do NOT bind the title via `.env.local`\n (`VITE_IDOS_TITLE_ID`) — that is a local-dev fallback for the raw template only, and it does not\n travel with the code.\n3. **Pick modules** — `list_modules` / `search`, then `get_module {id}` for each. Write its files\n into `src/modules/{id}/`. The base's systems are the modules with `inBase: true` (profile,\n inventory, invite, friends, quests, mailbox); add the game(s) with the modules each game `uses`, and any\n other system (shop, VIP, leaderboards…) only when the app needs it.\n4. **Register** — for each module add `import { {camelCase(id)}Module } from \"./modules/{id}\"` and\n push it into the `modules` array (e.g. `board-game` → `boardGameModule`). Then add the module to\n the \"Installed modules\" block of `IDOS.md` (one line: ``- `board-game` (0.1.0) — src/modules/board-game/``)\n — on platform projects the platform maintains that block itself.\n5. **Install deps** — the union of `runtimePackages` + each module's `dependencies`. Modules bring\n their own engine (three, phaser); the host brings react/react-dom/app-shell.\n6. **Run** — sign-in → straight INTO the game, never a \"Play\" click (owner's rule 07.11.2026): a game\n whose route says `inLobby: true` (idle-rpg) sits inside the lobby, the lobby's balances and tabs\n (the systems' screens; a tab hides itself while the title has nothing for it) around it; else the\n first game on the whole screen with a small button back to the lobby. The lobby draws the balances and the wallet; games hide their own copies by\n themselves (`sharedUi` roles).\n\nTo combine multiple genres into one game, see **idosgames-compose-modules**. To write or edit a\nmodule, see **idosgames-module-contract**.\n\n## Where state lives (decide this before writing the first save)\n\nThe project is client-side code in the player's browser. `localStorage`, module fields, and React\nstate are **not storage** — nothing there survives a device change, and nothing there is trusted.\n\n1. **A dedicated module owns it?** Use that module. Currencies, inventory, quests, characters,\n leaderboards, store purchases each have a service that enforces the rules server-side.\n2. **Otherwise, per-player data → `client.userCustomData`** — buckets `Private`/`Public` are\n client-writable (settings, cosmetics), `ReadOnly`/`Internal` are server-only. Anything a player\n could cheat by editing goes in the server-only buckets. See **user-custom-data**.\n3. **Shared by all players → `client.titleCustomData`** (event state, global counters, server\n thresholds, feature toggles). Read-only for clients. See **title-custom-data**.\n4. **Writing any of the server-only data, or any rule the player must not be able to fake** →\n a CloudCode handler, called with `client.cloudCode.execute(...)`. See **cloud-code**.\n\n## Two MCP surfaces: game CODE vs. a Title's live DATA\n\nThe platform exposes **two independent MCP servers** — don't confuse them:\n\n- **`@idosgames/mcp`** (this one) serves the **CODE registry**. Use it to WRITE a game's source:\n `get_host_scaffold`, `list_modules` / `search` / `get_module`, `get_manifest`, `list_skills` /\n `get_skill`. Transport: stdio (`npx -y @idosgames/mcp`). Its registry is bundled offline; to use the\n hosted copy set `IDOSGAMES_REGISTRY_URL=https://cloud.idosgames.com/drive/registry/latest` — a **base**\n the loader appends `/index.json`, `/modules/{id}.json`, etc. to (the base itself is not fetchable on\n R2; open `.../latest/index.json` to browse the catalog). Read-only, no auth. It never reads or changes\n a live Title's data.\n- **The Title-configuration MCP** (a separate backend server) owns a **live Title's DATA**. Use it to\n read/write the Title's `TitlePublicConfiguration` (`get_<field>` / `save_<field>`, or the whole\n model) and to generate assets (`generate_image` / `generate_audio` / `generate_text` /\n `generate_three_d` / `generate_video`). Transport: HTTP JSON-RPC at\n `POST https://site.idosgames.com/api/v2/mcp`; every tool call takes a `title_id` argument.\n Authorization is **OAuth 2.1** — there is no API key and nothing to paste. Connect it as a plain\n HTTP MCP server with **no headers**: your client gets a `401`, discovers the authorization\n server, registers itself, and opens a browser where the publisher picks which Titles and which\n permissions to grant. The token lives in your client's own credential store, so committed config\n holds only the URL:\n\n ```json\n {\n \"mcpServers\": {\n \"idosgames-title\": {\n \"type\": \"http\",\n \"url\": \"https://site.idosgames.com/api/v2/mcp\"\n }\n }\n }\n ```\n\n Permissions the publisher can grant: `config:read`, `config:write`, `cloudcode:write`,\n `ai:generate`, `kanban:read`, `kanban:write`, `build:write`. A grant is scoped to the Titles ticked on the consent screen, and the publisher\n can revoke it any time from **Connected apps** in the dashboard. If a call comes back\n `SCOPE_NOT_ALLOWED` or `TITLE_NOT_ALLOWED`, the token is fine — that permission or that Title\n simply was not granted; ask the publisher to re-authorize rather than retrying.\n\n **Shipping the game itself** (permission `build:write`) also goes through this server — the\n platform hosts it on the Title's own address (`{titleid}.idos.games`) and its idosgames.com page:\n\n 1. `begin_build_upload { title_id }` → `build_id`, a one-hour `upload_url`, `asset_base`.\n 2. Build the game as static files with that base: `vite build --base=<asset_base>` (a relative\n `./` base also works — Unity WebGL builds already are). Use the LIVE title id, never `-DEV`.\n 3. Zip the CONTENTS of the output folder (`index.html` at the zip root; a single `dist/` top folder\n is accepted) and upload it: `curl -T build.zip \"<upload_url>\"` — a plain PUT, no headers.\n 4. `finish_build_upload { title_id, build_id, deploy }` — unpacks it into a new version (it shows up\n in the dashboard's version history) and returns `TestUrl` (DEV address). `deploy: true` puts it\n live for players right away; without it, check `TestUrl` and call it again with `deploy: true`.\n Still unpacking when it returns → poll `get_build`. `list_builds` shows recent versions.\n\n A game that is live but whose Title is not published yet has no idosgames.com page — the\n response says so; the publisher publishes it from the dashboard.\n\n ⚠ **The build must be the game itself.** On the Title's idosgames.com page the game may embed\n (`<iframe>`) only platform hosts — never another website, with no setting to allow one. A\n \"wrapper\" build that signs in and then frames the publisher's own site shows **\"Content\n unavailable\"** there instead of the game: build and upload the real game. Network calls\n (`fetch`/WebSocket) from the embedded game reach platform hosts plus the Title's\n `Blockchain.IframeConnectHosts` (`save_blockchain`; wallet-connection hosts are always refused).\n\n Configuring a **fresh (empty) Title** so a game can actually run against it (currencies → game\n loop → bots) is its own checklist: see **idosgames-title-bootstrap**.\n\nRule of thumb: **game CODE → `@idosgames/mcp`; a Title's live config DATA and generated ASSETS → the\nbackend `v2/mcp` server.** Scaffolding a project and configuring/populating the Title it runs as are\ntwo different jobs on two different servers — connect the one that matches the task (or both).\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "idosgames-module-contract",
|
|
3
|
-
"description": "Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk: the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route registration, and the shared controller-box bridge between an imperative scene and React panels. Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg, voxelcraft,
|
|
4
|
-
"content": "---\nname: idosgames-module-contract\ndescription: >-\n Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk:\n the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route\n registration, and the shared controller-box bridge between an imperative scene and React panels.\n Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg,\n voxelcraft, mini-game, store, character, workshop…), or asks about defineModule,\n registerScene/registerPanel/registerRoute, ctx.effect / ctx.every (background work: badges, polling),\n ctx.features (register / open / setBadge / setAvailable),\n ctx.modes,\n activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic,\n ctx.content / ContentTypeHandler (letting the Workshop publish and open the game's content),\n ctx.multiplayer / registerGame (playing online through the lobby's rooms system),\n ctx.lobby / setHero (a game's showcase on the lobby's home — its character on a pedestal + \"Play\"),\n ctx.characterArt (the game's own hero drawn on the Heroes screen and other modules' screens),\n ctx.navigate, the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention.\n---\n\n# The module contract (@idosgames/module-sdk)\n\nA module is a manifest the host installs once. It exports `{camelCase(id)}Module` from its\n`index.ts` (e.g. `board-game` → `boardGameModule`, `idle-rpg` → `idleRpgModule`) — the host seeder\nderives the import name from the id, so this convention is required.\n\n```ts\nimport { defineModule } from \"@idosgames/module-sdk\";\n\nexport const boardGameModule = defineModule({\n id: \"board-game\",\n meta: { name: \"Board Game\", type: \"game\", genre: \"board\", engine: \"three\" }, // engine: three|phaser|dom\n setup(ctx) {\n // ctx.client (shared, authed) · ctx.events (typed cross-module bus) · ctx.sharedUi · ctx.surface\n ctx.registerScene(createScene(box)); // rendered engine scene (optional)\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: RootPanel }); // React UI (optional)\n ctx.registerRoute({ id: \"board-game\", label: \"Board\", icon: \"🎲\" }); // a mode → the lobby's \"Play\"\n },\n});\n```\n\nTwo kinds of module plug into the base (sign-in and the lobby, from `@idosgames/app-shell`):\n\n- a **game** (`meta.type: \"game\"`) registers a route: the lobby's \"Play\" launches it on the whole\n screen with a small \"To the lobby\" button over it (none with `ownExit: true` — the game leads back\n by itself, voxelcraft's pause menu); with `inLobby: true` on the route (idle-rpg) it\n instead lives INSIDE the lobby — the base keeps its balances and tabs around the game through the\n host's `header`/`footer` panel slots (the canvas and every other slot are laid out between them,\n so a game needs nothing for it) and the app starts in it;\n- a **system** (`meta.type: \"app\"`) registers a feature — its screen becomes a lobby tab:\n `ctx.features.register({ id, label, icon, group, order, primary?, available?, fullBleed?, Screen })`\n (`fullBleed: true` — the screen paints edge to edge, the lobby gives it the whole area). A tab\n that depends on title data registers with `available: false` and calls\n `ctx.features.setAvailable(id, ok, reason)` once it knows (details: **idosgames-ui-kit**).\n Give `label` as a function — `label: () => t(\"title\")` — so the tab follows the player's language\n switch without a reload (a plain string stays as registered).\n\n`ctx.modes.list()` / `current()` / `subscribe()` — every mode (id, label, icon, module, module type),\nfilled once all modules are installed (read it at render time, not inside `setup()`).\n`ctx.navigate(id)` switches (`\"lobby\"` is the base); called inside `setup()` it picks the mode the app\nstarts in. `ctx.features.open(id, args)` from a game (the lobby not on screen) switches to the lobby\nand opens that tab there.\n\n`ctx.lobby` — the base's lobby as seen by a game. A game NEVER draws a lobby of its own (no own\n\"Play\" screen, no own menu of creation screens): sign-in, balances, profile and every system are the\nbase's, and the game plugs into it —\n\n- `ctx.lobby.setHero({ mode, mount(host, stage), view(), subscribe(fn), beforePlay? })` puts its\n showcase on the lobby's home (\"Play\" tab), Fortnite-style: `mount` draws into an element filling\n the home behind the lobby's interface (any engine; stand the figure in `stage.spot()` — CSS px of\n the host, read it every frame), returns the cleanup; `view()` describes what the lobby lays out\n with the UI kit around it — `title` / `subtitle` under the figure, `actions` there (\"Edit\",\n \"Emote\" with a `menu`), and the `play` card next to the big \"Play\" (`caption`, `title`, `actions`,\n `more` for its \"⋯\"). The lobby's \"Play\" switches to `mode`. Reference: voxelcraft `src/lobby/hero.ts`;\n- the game's own sections (worlds, character editor, items…) are ordinary features\n (`ctx.features.register`, screens on the UI kit) — tabs next to the shop and the quests;\n- `ctx.lobby.available()` / `open()` — \"To the lobby\" in the game's pause menu (hide it when false).\n Works before the game's scene is mounted: keep what the tabs and the hero need outside the scene\n (voxelcraft: `src/hub.ts`) and hand the choice made there to the scene when it starts.\n\n`ctx.characterArt` — the game's hero on OTHER modules' screens (the `character` system's Heroes\nscreen: the hub, the hero card, the hero list). A game with a character of its own sets it once in\nsetup — `ctx.characterArt.set({ mount(host, subject, { mode }) })`: draw the hero described by\n`subject` (`characterId`, `classId`, `equipment` slot → `ItemID`, `skinId`, `locked`) filling `host`,\nstanding on its bottom edge; `mode` is `\"live\"` (a big view: it may move) or `\"still\"` (one frame, a\nlist of many); return `{ update(subject), destroy() }` — `update` comes on every change (a piece\nequipped). A screen reads `ctx.characterArt.get()` and falls back to its own picture when it is null.\nReference: idle-rpg `src/heroArt.ts` (its layered hero on a WebGL canvas, `CharacterCanvas`).\n\n`ctx.multiplayer` — a game that plays online registers itself (`registerGame({ mode, topology,\nchatMode, maxPlayers, joinInProgress, settings, start(room) })`) and takes the room the lobby's\nrooms system hands it on \"Play\"; it never creates or joins rooms (skill **multiplayer-realtime**).\n\nOptional static field: `sharedUi: { provides?: SharedUiRole[]; requires?: SharedUiRole[] }` — see\n\"Shared chrome\" below.\n\n`meta.type` is `game | app | ai-app`; `meta.engine` is `three | phaser | dom` (`dom` = no renderer —\na pure React/DOM app module). `setup` is called ONCE with `ctx: ModuleContext`.\n\n## EngineScene (the rendered part)\n\nA scene mounts into a bare `HTMLElement` (framework-free — this is why a vanilla Three game like\nvoxelcraft fits). The host's Mode Router drives it; only the active mode runs.\n\n```ts\nconst scene: EngineScene = {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext) {\n controller = new Controller(ctx.host);\n box.set(controller);\n },\n activate() {\n controller?.setRunning(true);\n }, // became the active mode → resume RAF\n suspend() {\n controller?.setRunning(false);\n }, // hidden → stop RAF (invariant: only active ticks)\n destroy() {\n controller?.destroy();\n }, // permanent teardown\n};\n```\n\n`mount` takes a context object (not a bare element) so it can grow — e.g. a host-shared renderer in\nthe composition era — without breaking the contract. A scene MUST stop its RAF on `suspend`.\n\n### Loading — never a still screen\n\nA scene's FIRST start goes behind the app's loading screen by itself: the host shows it (themed,\n\"Loading «<mode>»…\"), lets it paint, THEN calls `mount` — so heavy synchronous work in `mount`\n(generating terrain, building meshes) never looks like a frozen app. The screen goes away after the\nfirst frame, or — when the scene has more to do — when its optional `ready(): Promise<void>` resolves\n(voxelcraft: the last played world restored from storage). Never draw your own start-up spinner.\n\nLong work AFTER the start goes through `ctx.loading` — the same screen:\n\n- `await ctx.loading.track(fetchWorld(), \"Loading the world…\")` — async work;\n- `await ctx.loading.run(() => buildWorld(seed), \"Building the world…\")` — work that BLOCKS the page:\n the screen is shown and painted first (calling heavy code right after `begin()` freezes the page\n before the screen appears);\n- `const task = ctx.loading.begin(\"…\"); task.progress(0.4, \"Textures…\"); task.done();` — with progress.\n\n### Notifications — `ctx.notifications`\n\nThe app's one system of toasts and alert windows (the UI kit draws them, themed): never draw a banner\nof your own. `ctx.notifications.toast({ level, title, message, icon, image, source, key, actions })`\n— `level`: `info | success | warning | error | reward`; `source` defaults to the module's id (the\ntoast shows that screen's name and a tap opens it). `await ctx.notifications.alert({ level, title,\nmessage, actions: [{ id: \"go\", label: \"Open\", primary: true, openFeature: \"season\" }, { label: \"Later\" }] })`\nresolves with the pressed action's id (`null` — closed). React screens: `useNotify()` from the kit.\n\nA module that registers a scene MUST also publish its debug surface — `ctx.exposeToAgent({ state,\nactions, describeActions })` — and must NOT gate controls on Pointer Lock (unavailable in the\npreview's cross-origin iframe). Nothing inside a `<canvas>` is observable from the DOM, so without\nthe surface neither the AI Coder nor a human reviewer can tell what the game is doing. See the\n`idosgames-agent-debug-surface` skill.\n\n### Videos on idosgames.com — `focus()`, `audio()`, `ctx.clips.mark()`\n\nOn idosgames.com the player records the game: **Clip** (the last 30/60 s, always kept in the\nbackground), **Record** (start/stop), **Best moments** (made automatically), then trims, picks\nYouTube 16:9 / TikTok 9:16 / square and shares. The host (`@idosgames/app-shell`) records the active\nscene's `<canvas>` by itself — no code is needed for a basic video. Three optional touches make the\nvideos good; add them to every canvas game:\n\n```ts\n// in the scene object\nfocus() { // where the action is, 0–1 on the canvas — the vertical cut follows it\n const p = worldToScreen(player.position); // your own projection\n return { x: p.x / canvas.width, y: p.y / canvas.height };\n},\naudio() { // the game's sound, so videos are not silent\n return musicAndSfxDestination.stream; // a MediaStreamAudioDestinationNode your\n}, // master gain ALSO feeds (keep the speakers)\n\n// in setup() / gameplay — bright moments become the player's \"Best moments\" clips\nctx.clips.mark(\"boss_down\", 8); // weight 1–10: a boss 8, a record 6, a level-up 3, a silly death 4\n```\n\n- A first-person game needs no `focus` (the action is in the centre). Do not call it per frame —\n the host samples it a few times a second.\n- `audio()` returns `null` until your AudioContext exists (it is created on the first click) — fine,\n the host asks again. Route every sound through ONE master node and tap that (VoxelCraft:\n `systems/Audio.ts` `output()` / `recordStream()`).\n- Mark real moments only — the session keeps the 3 heaviest; close marks merge and add up.\n- An analytics event the publisher flagged as a highlight (`Analytics.Events[name].Highlight`) and\n loud voice chat are marks too, without any code.\n- DOM HUD panels are NOT in the video (only the canvas) — a clean video reads better in a feed.\n- Reference: `modules/voxelcraft` (`scene.ts` `audio`, `game/wire.ts` marks).\n\n### `capture()` — one still frame\n\nA scene may also answer the platform's still-frame request through the optional\n`capture?(): Promise<Blob | null>`. The host (`@idosgames/app-shell`) wires the request itself —\na module only has to return a picture of its active scene.\n\n**Implement it in any scene that renders to a canvas.** Without it the host falls back to reading\nthe `<canvas>` from outside, and for WebGL that is a trap: a renderer created WITHOUT\n`preserveDrawingBuffer` (the default, and the right default — the flag costs memory and a copy every\nframe) has already cleared its buffer by the time anyone reads it, so `toBlob()` returns a **fully\nblack** image and reports nothing. The host rejects single-colour frames, so the player gets the\ngame's cover instead of their moment.\n\nRender and read in the SAME tick:\n\n```ts\n// three.js\ncapture() {\n return new Promise((resolve) => {\n renderer.render(scene, camera); // draw now…\n renderer.domElement.toBlob(resolve, \"image/png\"); // …and read before the buffer is cleared\n });\n},\n\n// Phaser — use its own snapshot, it handles the timing\ncapture() {\n return new Promise((resolve) => {\n setTimeout(() => resolve(null), 1500); // no frame came (context lost) → answer \"no\", don't hang\n game.loop.wake(); // a sleeping loop never draws again → snapshot never fires\n game.renderer.snapshot((image) => {\n if (!(image instanceof HTMLImageElement)) return resolve(null);\n const c = document.createElement(\"canvas\");\n c.width = image.naturalWidth || image.width;\n c.height = image.naturalHeight || image.height;\n c.getContext(\"2d\")?.drawImage(image, 0, 0);\n c.toBlob(resolve, \"image/png\");\n });\n });\n},\n```\n\nAlways answer — with a Blob or `null`. A `capture()` that never settles makes the site wait out its\ntimeout before it falls back, and the player stares at a spinner.\n\nReturn `null` when there is nothing worth showing (loading, no scene yet) — the site then uses the\ncover. Only the scene is captured, not the React panels over it: a shot for a feed reads better\nwithout the HUD. DOM-only modules (no canvas) simply omit `capture`. Working references:\n`modules/board-game` and `modules/voxelcraft` (three), `modules/idle-rpg` (Phaser).\n\n## UiPanel (the React part)\n\nPanels are React components the host renders inside its provider stack (client + status already\nprovided). Use `@idosgames/react` hooks (`useIDosGamesClient`, `useUserState`) — do NOT re-create\nproviders.\n\n- `slot`: `hud | sidebar | overlay | modal`.\n- `activeOnly` (default true): show only while this module's mode is active. Set `false` for shared\n chrome that stays across every mode (e.g. a persistent HUD).\n- `overlay`/`sidebar` layers sit between the shared HUD and the nav (the host measures both), so a\n panel positioned `absolute` inside its layer never ends up under them. Things drawn outside the\n layers use `var(--idos-safe-top, 0px)` / `var(--idos-safe-bottom, 0px)`.\n- A `hud` panel's wrapper is `display: contents`: the panel is a direct flex child of the HUD row\n (can `flex: 1`) and must set `pointer-events: auto` only on its controls.\n\n## Shared chrome (`sharedUi`)\n\nRoles: `currency-bar` (virtual-currency balances), `wallet` (crypto wallet entry — `LazyWalletPanel`),\n`status` (the `useStatus()` line), `account` (Log out + player ID), `menu` (the menu of features),\n`modes` (the switch between modes). The base takes `currency-bar`, `wallet`, `account`, `menu` and\n`modes` — its lobby draws them; `status` stays with each game. A module that draws one for every\nmode declares `sharedUi: { provides: [...] }` on the module object — statically, so the host resolves\nowners before any `setup()` and the answer never depends on module order.\n\n**Rule for templates: yield every role you draw yourself.** Read it once in `setup()` and close over\nthe answer (it is fixed for the session):\n\n```ts\nsetup(ctx) {\n const chrome = {\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n balances: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n };\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: makeRootPanel(box, chrome) });\n}\n```\n\nHide only the SHARED pieces — genre UI (the board's stage/tile/cycle chips) always stays. Never\nwrap your UI in your own `<StatusProvider>`: the host provides ONE, and a nested one would swallow\nyour messages so the shared status line never sees them. A module that needs a role but does not\ndraw it declares `sharedUi: { requires: [\"currency-bar\"] }`. `ctx.sharedUi.ownerOf(role)` names the\nprovider (or `null`). A full-screen mode (its own bottom tab bar) that shows the player ID and\n\"Log out\" in its own header declares `sharedUi: { drawsOwn: [\"account\"] }` and draws them only while\n`shouldDraw(\"account\")`: the host then hides its floating bottom-left copy while that mode is on\nscreen (it would cover the tab bar) and keeps it in the other modes. `drawsOwn` takes no role —\nwith the base installed the lobby draws the account and the mode hides its copy.\n\n## Events (`ctx.events`)\n\nTyped topic tokens — `defineTopic(\"<your-id>:<event>@1\", shape({ … }))` from\n`@idosgames/module-sdk`; emit only your own namespace; to listen to another module, copy its topic\nand payload descriptor from the catalog into your own `defineTopic` (never import it); declare both\nin `module.meta.json` (`events.emits` / `events.listens`). **Subscribe in `setup()`**, not in a panel\neffect — `activeOnly` panels unmount with their mode and miss events; store what arrives in a small\nstore the panel reads. Full rules and examples: **idosgames-compose-modules** (\"Signals between\nmodules\"). The string overloads (`emit(\"x\", …)`) are deprecated.\n\n## Content types (`ctx.content`) and switching modes (`ctx.navigate`)\n\nA game whose players make things (worlds, levels, skins) registers a **content type handler** so the\nshared Workshop module can publish and open them — the Workshop never imports the game:\n\n```ts\nsetup(ctx) {\n ctx.content.registerType({\n type: \"voxelcraft.world\", // = a key of Workshop.ContentTypes in the title config\n label: \"VoxelCraft world\",\n icon: \"⛏️\",\n modeId: \"voxelcraft\", // route to switch to after open()\n async listLocal() { return saves.map((s) => ({ id: s.id, name: s.name, updatedAt: s.at })); },\n async capture(localId) { // roles/MIME types must fit the title's content type config\n return { files: [{ role: \"main\", contentType: \"application/json\", data: json }],\n thumbnail: { contentType: \"image/webp\", data: webp }, suggestedTitle: name };\n },\n async open(content) { importSave(content.files[0].data); },\n });\n}\n```\n\nHandlers registered in `setup()` are removed with the module; `registerType` returns an unregister\nfunction for anything dynamic. `ctx.navigate(modeId)` switches the host to another module's route\n(the Workshop uses it to jump into the game after `open`); an unknown id is ignored. Details:\n**workshop-system**.\n\n## Bridging scene ↔ panel\n\nThe scene creates its controller at `mount` (needs the canvas), but panels render before that. Share\nit with a one-slot observable and read it with `useSyncExternalStore`:\n\n```ts\nexport function createControllerBox<T>() {\n /* get/set/subscribe */\n}\n// panel: const controller = useSyncExternalStore(box.subscribe, box.get); if (!controller) return <Loading/>;\n```\n\nFor the module's controller React context use the shared factory instead of hand-writing it:\n\n```ts\nexport const [BoardControllerProvider, useBoardController] =\n createControllerContext<BoardController>(\"BoardController\"); // from @idosgames/react\n```\n\n## Layout and boundaries\n\nA module is one folder, `src/modules/<id>/`. It imports only its own files, the game's shared code\nin `src/shared/` and npm packages — never another module's files or host files. Talk to other\nmodules through the shared `ctx.client` (durable state) and `ctx.events` (live signals, typed\ntopics `<module-id>:<event>@<major>` declared in `module.meta.json`). A module shipped in the catalog is fully self-contained — it never uses\n`src/shared/`, so it installs into any game; a game's own module may use it, and its shared code is\ncopied into it when it is published for other creators.\n\n```\nsrc/modules/<id>/\n index.ts export { <camelCaseId>Module } from \"./module\";\n module.ts defineModule({ … })\n module.meta.json manifest: type (template|feature), summary, provides, tags, version, author\n components/ game/ data/ react/ — as needed\n```\n\nEvery module carries its own `module.meta.json` (the `ModuleManifest` shape), including a game's own\nmodules. The full standard — where a new feature goes, file size, documentation — is\n**idosgames-project-structure**.\n\n## Dependencies\n\nDeclare only the module's UNIQUE deps (e.g. `phaser` for idle-rpg, `three` for board/voxel) plus the\nshared baseline (react, @idosgames/*). The platform pins shared libs identically across modules; two\nmodules must not request different versions of one package (the build allowlist rejects it).\n\nStudy a real module via `get_module {id}` (MCP) before writing a new one.\n",
|
|
3
|
+
"description": "Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk: the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route registration, and the shared controller-box bridge between an imperative scene and React panels. Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg, voxelcraft, kit-game, store, character, workshop…), or asks about defineModule, registerScene/registerPanel/registerRoute, ctx.effect / ctx.every (background work: badges, polling), ctx.features (register / open / setBadge / setAvailable), ctx.modes, activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic, ctx.content / ContentTypeHandler (letting the Workshop publish and open the game's content), ctx.multiplayer / registerGame (playing online through the lobby's rooms system), ctx.lobby / setHero (a game's showcase on the lobby's home — its character on a pedestal + \"Play\"), ctx.characterArt (the game's own hero drawn on the Heroes screen and other modules' screens), ctx.navigate, the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention.",
|
|
4
|
+
"content": "---\nname: idosgames-module-contract\ndescription: >-\n Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk:\n the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route\n registration, and the shared controller-box bridge between an imperative scene and React panels.\n Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg,\n voxelcraft, kit-game, store, character, workshop…), or asks about defineModule,\n registerScene/registerPanel/registerRoute, ctx.effect / ctx.every (background work: badges, polling),\n ctx.features (register / open / setBadge / setAvailable),\n ctx.modes,\n activate/suspend, SceneMountContext, sharedUi / ctx.sharedUi.shouldDraw, ctx.events / defineTopic,\n ctx.content / ContentTypeHandler (letting the Workshop publish and open the game's content),\n ctx.multiplayer / registerGame (playing online through the lobby's rooms system),\n ctx.lobby / setHero (a game's showcase on the lobby's home — its character on a pedestal + \"Play\"),\n ctx.characterArt (the game's own hero drawn on the Heroes screen and other modules' screens),\n ctx.navigate, the --idos-safe-* layout variables, or the {camelCase(id)}Module export convention.\n---\n\n# The module contract (@idosgames/module-sdk)\n\nA module is a manifest the host installs once. It exports `{camelCase(id)}Module` from its\n`index.ts` (e.g. `board-game` → `boardGameModule`, `idle-rpg` → `idleRpgModule`) — the host seeder\nderives the import name from the id, so this convention is required.\n\n```ts\nimport { defineModule } from \"@idosgames/module-sdk\";\n\nexport const boardGameModule = defineModule({\n id: \"board-game\",\n meta: { name: \"Board Game\", type: \"game\", genre: \"board\", engine: \"three\" }, // engine: three|phaser|dom\n setup(ctx) {\n // ctx.client (shared, authed) · ctx.events (typed cross-module bus) · ctx.sharedUi · ctx.surface\n ctx.registerScene(createScene(box)); // rendered engine scene (optional)\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: RootPanel }); // React UI (optional)\n ctx.registerRoute({ id: \"board-game\", label: \"Board\", icon: \"🎲\" }); // a mode → the lobby's \"Play\"\n },\n});\n```\n\nTwo kinds of module plug into the base (sign-in and the lobby, from `@idosgames/app-shell`):\n\n- a **game** (`meta.type: \"game\"`) registers a route: the lobby's \"Play\" launches it on the whole\n screen with a small \"To the lobby\" button over it (none with `ownExit: true` — the game leads back\n by itself, voxelcraft's pause menu); with `inLobby: true` on the route (idle-rpg) it\n instead lives INSIDE the lobby — the base keeps its balances and tabs around the game through the\n host's `header`/`footer` panel slots (the canvas and every other slot are laid out between them,\n so a game needs nothing for it) and the app starts in it;\n- a **system** (`meta.type: \"app\"`) registers a feature — its screen becomes a lobby tab:\n `ctx.features.register({ id, label, icon, group, order, primary?, available?, fullBleed?, Screen })`\n (`fullBleed: true` — the screen paints edge to edge, the lobby gives it the whole area). A tab\n that depends on title data registers with `available: false` and calls\n `ctx.features.setAvailable(id, ok, reason)` once it knows (details: **idosgames-ui-kit**).\n Give `label` as a function — `label: () => t(\"title\")` — so the tab follows the player's language\n switch without a reload (a plain string stays as registered).\n\n`ctx.modes.list()` / `current()` / `subscribe()` — every mode (id, label, icon, module, module type),\nfilled once all modules are installed (read it at render time, not inside `setup()`).\n`ctx.navigate(id)` switches (`\"lobby\"` is the base); called inside `setup()` it picks the mode the app\nstarts in. `ctx.features.open(id, args)` from a game (the lobby not on screen) switches to the lobby\nand opens that tab there.\n\n`ctx.lobby` — the base's lobby as seen by a game. A game NEVER draws a lobby of its own (no own\n\"Play\" screen, no own menu of creation screens): sign-in, balances, profile and every system are the\nbase's, and the game plugs into it —\n\n- `ctx.lobby.setHero({ mode, mount(host, stage), view(), subscribe(fn), beforePlay? })` puts its\n showcase on the lobby's home (\"Play\" tab), Fortnite-style: `mount` draws into an element filling\n the home behind the lobby's interface (any engine; stand the figure in `stage.spot()` — CSS px of\n the host, read it every frame), returns the cleanup; `view()` describes what the lobby lays out\n with the UI kit around it — `title` / `subtitle` under the figure, `actions` there (\"Edit\",\n \"Emote\" with a `menu`), and the `play` card next to the big \"Play\" (`caption`, `title`, `actions`,\n `more` for its \"⋯\"). The lobby's \"Play\" switches to `mode`. Reference: voxelcraft `src/lobby/hero.ts`;\n- the game's own sections (worlds, character editor, items…) are ordinary features\n (`ctx.features.register`, screens on the UI kit) — tabs next to the shop and the quests;\n- `ctx.lobby.available()` / `open()` — \"To the lobby\" in the game's pause menu (hide it when false).\n Works before the game's scene is mounted: keep what the tabs and the hero need outside the scene\n (voxelcraft: `src/hub.ts`) and hand the choice made there to the scene when it starts.\n\n`ctx.characterArt` — the game's hero on OTHER modules' screens (the `character` system's Heroes\nscreen: the hub, the hero card, the hero list). A game with a character of its own sets it once in\nsetup — `ctx.characterArt.set({ mount(host, subject, { mode }) })`: draw the hero described by\n`subject` (`characterId`, `classId`, `equipment` slot → `ItemID`, `skinId`, `locked`) filling `host`,\nstanding on its bottom edge; `mode` is `\"live\"` (a big view: it may move) or `\"still\"` (one frame, a\nlist of many); return `{ update(subject), destroy() }` — `update` comes on every change (a piece\nequipped). A screen reads `ctx.characterArt.get()` and falls back to its own picture when it is null.\nReference: idle-rpg `src/heroArt.ts` (its layered hero on a WebGL canvas, `CharacterCanvas`).\n\n`ctx.multiplayer` — a game that plays online registers itself (`registerGame({ mode, topology,\nchatMode, maxPlayers, joinInProgress, settings, start(room) })`) and takes the room the lobby's\nrooms system hands it on \"Play\"; it never creates or joins rooms (skill **multiplayer-realtime**).\n\nOptional static field: `sharedUi: { provides?: SharedUiRole[]; requires?: SharedUiRole[] }` — see\n\"Shared chrome\" below.\n\n`meta.type` is `game | app | ai-app`; `meta.engine` is `three | phaser | dom` (`dom` = no renderer —\na pure React/DOM app module). `setup` is called ONCE with `ctx: ModuleContext`.\n\n## EngineScene (the rendered part)\n\nA scene mounts into a bare `HTMLElement` (framework-free — this is why a vanilla Three game like\nvoxelcraft fits). The host's Mode Router drives it; only the active mode runs.\n\n```ts\nconst scene: EngineScene = {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext) {\n controller = new Controller(ctx.host);\n box.set(controller);\n },\n activate() {\n controller?.setRunning(true);\n }, // became the active mode → resume RAF\n suspend() {\n controller?.setRunning(false);\n }, // hidden → stop RAF (invariant: only active ticks)\n destroy() {\n controller?.destroy();\n }, // permanent teardown\n};\n```\n\n`mount` takes a context object (not a bare element) so it can grow — e.g. a host-shared renderer in\nthe composition era — without breaking the contract. A scene MUST stop its RAF on `suspend`.\n\n### Loading — never a still screen\n\nA scene's FIRST start goes behind the app's loading screen by itself: the host shows it (themed,\n\"Loading «<mode>»…\"), lets it paint, THEN calls `mount` — so heavy synchronous work in `mount`\n(generating terrain, building meshes) never looks like a frozen app. The screen goes away after the\nfirst frame, or — when the scene has more to do — when its optional `ready(): Promise<void>` resolves\n(voxelcraft: the last played world restored from storage). Never draw your own start-up spinner.\n\nLong work AFTER the start goes through `ctx.loading` — the same screen:\n\n- `await ctx.loading.track(fetchWorld(), \"Loading the world…\")` — async work;\n- `await ctx.loading.run(() => buildWorld(seed), \"Building the world…\")` — work that BLOCKS the page:\n the screen is shown and painted first (calling heavy code right after `begin()` freezes the page\n before the screen appears);\n- `const task = ctx.loading.begin(\"…\"); task.progress(0.4, \"Textures…\"); task.done();` — with progress.\n\n### Notifications — `ctx.notifications`\n\nThe app's one system of toasts and alert windows (the UI kit draws them, themed): never draw a banner\nof your own. `ctx.notifications.toast({ level, title, message, icon, image, source, key, actions })`\n— `level`: `info | success | warning | error | reward`; `source` defaults to the module's id (the\ntoast shows that screen's name and a tap opens it). `await ctx.notifications.alert({ level, title,\nmessage, actions: [{ id: \"go\", label: \"Open\", primary: true, openFeature: \"season\" }, { label: \"Later\" }] })`\nresolves with the pressed action's id (`null` — closed). React screens: `useNotify()` from the kit.\n\nA module that registers a scene MUST also publish its debug surface — `ctx.exposeToAgent({ state,\nactions, describeActions })` — and must NOT gate controls on Pointer Lock (unavailable in the\npreview's cross-origin iframe). Nothing inside a `<canvas>` is observable from the DOM, so without\nthe surface neither the AI Coder nor a human reviewer can tell what the game is doing. See the\n`idosgames-agent-debug-surface` skill.\n\n### Videos on idosgames.com — `focus()`, `audio()`, `ctx.clips.mark()`\n\nOn idosgames.com the player records the game: **Clip** (the last 30/60 s, always kept in the\nbackground), **Record** (start/stop), **Best moments** (made automatically), then trims, picks\nYouTube 16:9 / TikTok 9:16 / square and shares. The host (`@idosgames/app-shell`) records the active\nscene's `<canvas>` by itself — no code is needed for a basic video. Three optional touches make the\nvideos good; add them to every canvas game:\n\n```ts\n// in the scene object\nfocus() { // where the action is, 0–1 on the canvas — the vertical cut follows it\n const p = worldToScreen(player.position); // your own projection\n return { x: p.x / canvas.width, y: p.y / canvas.height };\n},\naudio() { // the game's sound, so videos are not silent\n return musicAndSfxDestination.stream; // a MediaStreamAudioDestinationNode your\n}, // master gain ALSO feeds (keep the speakers)\n\n// in setup() / gameplay — bright moments become the player's \"Best moments\" clips\nctx.clips.mark(\"boss_down\", 8); // weight 1–10: a boss 8, a record 6, a level-up 3, a silly death 4\n```\n\n- A first-person game needs no `focus` (the action is in the centre). Do not call it per frame —\n the host samples it a few times a second.\n- `audio()` returns `null` until your AudioContext exists (it is created on the first click) — fine,\n the host asks again. Route every sound through ONE master node and tap that (VoxelCraft:\n `systems/Audio.ts` `output()` / `recordStream()`).\n- Mark real moments only — the session keeps the 3 heaviest; close marks merge and add up.\n- An analytics event the publisher flagged as a highlight (`Analytics.Events[name].Highlight`) and\n loud voice chat are marks too, without any code.\n- DOM HUD panels are NOT in the video (only the canvas) — a clean video reads better in a feed.\n- Reference: `modules/voxelcraft` (`scene.ts` `audio`, `game/wire.ts` marks).\n\n### `capture()` — one still frame\n\nA scene may also answer the platform's still-frame request through the optional\n`capture?(): Promise<Blob | null>`. The host (`@idosgames/app-shell`) wires the request itself —\na module only has to return a picture of its active scene.\n\n**Implement it in any scene that renders to a canvas.** Without it the host falls back to reading\nthe `<canvas>` from outside, and for WebGL that is a trap: a renderer created WITHOUT\n`preserveDrawingBuffer` (the default, and the right default — the flag costs memory and a copy every\nframe) has already cleared its buffer by the time anyone reads it, so `toBlob()` returns a **fully\nblack** image and reports nothing. The host rejects single-colour frames, so the player gets the\ngame's cover instead of their moment.\n\nRender and read in the SAME tick:\n\n```ts\n// three.js\ncapture() {\n return new Promise((resolve) => {\n renderer.render(scene, camera); // draw now…\n renderer.domElement.toBlob(resolve, \"image/png\"); // …and read before the buffer is cleared\n });\n},\n\n// Phaser — use its own snapshot, it handles the timing\ncapture() {\n return new Promise((resolve) => {\n setTimeout(() => resolve(null), 1500); // no frame came (context lost) → answer \"no\", don't hang\n game.loop.wake(); // a sleeping loop never draws again → snapshot never fires\n game.renderer.snapshot((image) => {\n if (!(image instanceof HTMLImageElement)) return resolve(null);\n const c = document.createElement(\"canvas\");\n c.width = image.naturalWidth || image.width;\n c.height = image.naturalHeight || image.height;\n c.getContext(\"2d\")?.drawImage(image, 0, 0);\n c.toBlob(resolve, \"image/png\");\n });\n });\n},\n```\n\nAlways answer — with a Blob or `null`. A `capture()` that never settles makes the site wait out its\ntimeout before it falls back, and the player stares at a spinner.\n\nReturn `null` when there is nothing worth showing (loading, no scene yet) — the site then uses the\ncover. Only the scene is captured, not the React panels over it: a shot for a feed reads better\nwithout the HUD. DOM-only modules (no canvas) simply omit `capture`. Working references:\n`modules/board-game` and `modules/voxelcraft` (three), `modules/idle-rpg` (Phaser).\n\n## UiPanel (the React part)\n\nPanels are React components the host renders inside its provider stack (client + status already\nprovided). Use `@idosgames/react` hooks (`useIDosGamesClient`, `useUserState`) — do NOT re-create\nproviders.\n\n- `slot`: `hud | sidebar | overlay | modal`.\n- `activeOnly` (default true): show only while this module's mode is active. Set `false` for shared\n chrome that stays across every mode (e.g. a persistent HUD).\n- `overlay`/`sidebar` layers sit between the shared HUD and the nav (the host measures both), so a\n panel positioned `absolute` inside its layer never ends up under them. Things drawn outside the\n layers use `var(--idos-safe-top, 0px)` / `var(--idos-safe-bottom, 0px)`.\n- A `hud` panel's wrapper is `display: contents`: the panel is a direct flex child of the HUD row\n (can `flex: 1`) and must set `pointer-events: auto` only on its controls.\n\n## Shared chrome (`sharedUi`)\n\nRoles: `currency-bar` (virtual-currency balances), `wallet` (crypto wallet entry — `LazyWalletPanel`),\n`status` (the `useStatus()` line), `account` (Log out + player ID), `menu` (the menu of features),\n`modes` (the switch between modes). The base takes `currency-bar`, `wallet`, `account`, `menu` and\n`modes` — its lobby draws them; `status` stays with each game. A module that draws one for every\nmode declares `sharedUi: { provides: [...] }` on the module object — statically, so the host resolves\nowners before any `setup()` and the answer never depends on module order.\n\n**Rule for templates: yield every role you draw yourself.** Read it once in `setup()` and close over\nthe answer (it is fixed for the session):\n\n```ts\nsetup(ctx) {\n const chrome = {\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n balances: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n };\n ctx.registerPanel({ id: \"root\", slot: \"overlay\", component: makeRootPanel(box, chrome) });\n}\n```\n\nHide only the SHARED pieces — genre UI (the board's stage/tile/cycle chips) always stays. Never\nwrap your UI in your own `<StatusProvider>`: the host provides ONE, and a nested one would swallow\nyour messages so the shared status line never sees them. A module that needs a role but does not\ndraw it declares `sharedUi: { requires: [\"currency-bar\"] }`. `ctx.sharedUi.ownerOf(role)` names the\nprovider (or `null`). A full-screen mode (its own bottom tab bar) that shows the player ID and\n\"Log out\" in its own header declares `sharedUi: { drawsOwn: [\"account\"] }` and draws them only while\n`shouldDraw(\"account\")`: the host then hides its floating bottom-left copy while that mode is on\nscreen (it would cover the tab bar) and keeps it in the other modes. `drawsOwn` takes no role —\nwith the base installed the lobby draws the account and the mode hides its copy.\n\n## Events (`ctx.events`)\n\nTyped topic tokens — `defineTopic(\"<your-id>:<event>@1\", shape({ … }))` from\n`@idosgames/module-sdk`; emit only your own namespace; to listen to another module, copy its topic\nand payload descriptor from the catalog into your own `defineTopic` (never import it); declare both\nin `module.meta.json` (`events.emits` / `events.listens`). **Subscribe in `setup()`**, not in a panel\neffect — `activeOnly` panels unmount with their mode and miss events; store what arrives in a small\nstore the panel reads. Full rules and examples: **idosgames-compose-modules** (\"Signals between\nmodules\"). The string overloads (`emit(\"x\", …)`) are deprecated.\n\n## Content types (`ctx.content`) and switching modes (`ctx.navigate`)\n\nA game whose players make things (worlds, levels, skins) registers a **content type handler** so the\nshared Workshop module can publish and open them — the Workshop never imports the game:\n\n```ts\nsetup(ctx) {\n ctx.content.registerType({\n type: \"voxelcraft.world\", // = a key of Workshop.ContentTypes in the title config\n label: \"VoxelCraft world\",\n icon: \"⛏️\",\n modeId: \"voxelcraft\", // route to switch to after open()\n async listLocal() { return saves.map((s) => ({ id: s.id, name: s.name, updatedAt: s.at })); },\n async capture(localId) { // roles/MIME types must fit the title's content type config\n return { files: [{ role: \"main\", contentType: \"application/json\", data: json }],\n thumbnail: { contentType: \"image/webp\", data: webp }, suggestedTitle: name };\n },\n async open(content) { importSave(content.files[0].data); },\n });\n}\n```\n\nHandlers registered in `setup()` are removed with the module; `registerType` returns an unregister\nfunction for anything dynamic. `ctx.navigate(modeId)` switches the host to another module's route\n(the Workshop uses it to jump into the game after `open`); an unknown id is ignored. Details:\n**workshop-system**.\n\n## Bridging scene ↔ panel\n\nThe scene creates its controller at `mount` (needs the canvas), but panels render before that. Share\nit with a one-slot observable and read it with `useSyncExternalStore`:\n\n```ts\nexport function createControllerBox<T>() {\n /* get/set/subscribe */\n}\n// panel: const controller = useSyncExternalStore(box.subscribe, box.get); if (!controller) return <Loading/>;\n```\n\nFor the module's controller React context use the shared factory instead of hand-writing it:\n\n```ts\nexport const [BoardControllerProvider, useBoardController] =\n createControllerContext<BoardController>(\"BoardController\"); // from @idosgames/react\n```\n\n## Layout and boundaries\n\nA module is one folder, `src/modules/<id>/`. It imports only its own files, the game's shared code\nin `src/shared/` and npm packages — never another module's files or host files. Talk to other\nmodules through the shared `ctx.client` (durable state) and `ctx.events` (live signals, typed\ntopics `<module-id>:<event>@<major>` declared in `module.meta.json`). A module shipped in the catalog is fully self-contained — it never uses\n`src/shared/`, so it installs into any game; a game's own module may use it, and its shared code is\ncopied into it when it is published for other creators.\n\n```\nsrc/modules/<id>/\n index.ts export { <camelCaseId>Module } from \"./module\";\n module.ts defineModule({ … })\n module.meta.json manifest: type (template|feature), summary, provides, tags, version, author\n components/ game/ data/ react/ — as needed\n```\n\nEvery module carries its own `module.meta.json` (the `ModuleManifest` shape), including a game's own\nmodules. The full standard — where a new feature goes, file size, documentation — is\n**idosgames-project-structure**.\n\n## Dependencies\n\nDeclare only the module's UNIQUE deps (e.g. `phaser` for idle-rpg, `three` for board/voxel) plus the\nshared baseline (react, @idosgames/*). The platform pins shared libs identically across modules; two\nmodules must not request different versions of one package (the build allowlist rejects it).\n\nStudy a real module via `get_module {id}` (MCP) before writing a new one.\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "mini-game",
|
|
3
|
-
"meta": {
|
|
4
|
-
"name": "Mini-game",
|
|
5
|
-
"type": "game",
|
|
6
|
-
"engine": "dom",
|
|
7
|
-
"genre": "casual"
|
|
8
|
-
},
|
|
9
|
-
"catalog": {
|
|
10
|
-
"type": "template",
|
|
11
|
-
"summary": "The smallest complete game: a 15-second 'catch the stars' round launched from the lobby's Play button, with a leaderboard score.",
|
|
12
|
-
"description": "A tiny game of its own mode (React/DOM, no engine): tap as many stars as you can in 15 seconds, gold stars are worth 3. The score goes to the title's leaderboard, and a round counts for quests that ask for games_played, for timed events that pay for the 'play_round' action and for season chains open to clients. The platform gives it to a new title when no other game fits the idea — it shows where a game plugs into the base (sign-in → lobby → Play) and is meant to be replaced by the real game. No demo settings of its own: it plays on the base's world.",
|
|
13
|
-
"provides": [
|
|
14
|
-
"a small tap game launched from the lobby",
|
|
15
|
-
"score submitted to the leaderboard",
|
|
16
|
-
"rounds counted by quests, events and the season",
|
|
17
|
-
"placeholder game to replace with the real one"
|
|
18
|
-
],
|
|
19
|
-
"tags": [
|
|
20
|
-
"starter",
|
|
21
|
-
"mini-game",
|
|
22
|
-
"casual",
|
|
23
|
-
"tap",
|
|
24
|
-
"leaderboard"
|
|
25
|
-
],
|
|
26
|
-
"author": {
|
|
27
|
-
"name": "iDos Games",
|
|
28
|
-
"url": "https://idosgames.com"
|
|
29
|
-
},
|
|
30
|
-
"version": "0.1.4"
|
|
31
|
-
},
|
|
32
|
-
"dependencies": {
|
|
33
|
-
"@idosgames/core": "0.26.1",
|
|
34
|
-
"@idosgames/module-sdk": "0.13.3",
|
|
35
|
-
"@idosgames/react": "0.7.1",
|
|
36
|
-
"react": "19.2.7"
|
|
37
|
-
},
|
|
38
|
-
"contentHash": "4e855049c6c583010b8e9b39d552de30cd8544278690fe903fcc8d2a7e451382",
|
|
39
|
-
"files": [
|
|
40
|
-
{
|
|
41
|
-
"path": "i18n.ts",
|
|
42
|
-
"content": "import { makeT } from \"@idosgames/react/ui\";\n\n// Strings of the mini-game. The leaderboard's name is the publisher's word (title config).\n\nconst EN = {\n title: \"Star catch\",\n play: \"Play\",\n playAgain: \"Play again\",\n tapStars: \"Tap the stars!\",\n timeLeft: \"Time\",\n score: \"Score\",\n yourScore: \"Your score\",\n submitted: \"Submitted to the leaderboard\",\n seasonStatus: \"season status\",\n toTable: \"Leaderboard\",\n best: \"Tap as many stars as you can in 15 seconds. Gold stars are worth 3.\",\n} as const;\n\nexport type StringKey = keyof typeof EN;\n\nexport const t = makeT<StringKey>(EN, {\n title: \"Ловец звёзд\",\n play: \"Играть\",\n playAgain: \"Ещё раз\",\n tapStars: \"Ловите звёзды!\",\n timeLeft: \"Время\",\n score: \"Очки\",\n yourScore: \"Ваш результат\",\n submitted: \"Результат отправлен в таблицу\",\n seasonStatus: \"статуса сезона\",\n toTable: \"Таблица лидеров\",\n best: \"Поймайте как можно больше звёзд за 15 секунд. Золотая — 3 очка.\",\n});\n"
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
"path": "index.ts",
|
|
46
|
-
"content": "// @idosgames/mod-mini-game — \"Star catch\", the smallest complete game module: a 15-second tap round\n// launched from the lobby's Play button, with a leaderboard score.\n//\n// Primary export is the module manifest; the pieces are exported too, so a project can reuse the\n// round inside its own game after copying this into src/modules/.\n\nexport { miniGameModule } from \"./module\";\nexport { makeMiniGamePanel } from \"./MiniGamePanel\";\nexport { MiniGame } from \"./MiniGame\";\nexport * from \"./model\";\n"
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
"path": "MiniGame.tsx",
|
|
50
|
-
"content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport {\n Button,\n Icon,\n outlined,\n panel,\n useSound,\n v,\n} from \"@idosgames/react/ui\";\nimport { t } from \"./i18n\";\n\n// \"Tap the stars\": a 15-second round that produces a score for the leaderboard. Deliberately tiny —\n// the placeholder a project replaces with its real game, keeping the same contract: play, then hand\n// the score to onFinish (the play screen submits it and reports the round to quests).\n\nconst ROUND_MS = 15_000;\nconst SPAWN_MS = 520;\nconst LIFE_MS = 1_150;\n\ninterface Star {\n id: number;\n x: number;\n y: number;\n golden: boolean;\n bornAt: number;\n}\n\nexport function MiniGame({\n onFinish,\n onCancel,\n height = 380,\n}: {\n onFinish: (score: number) => void;\n onCancel: () => void;\n height?: number;\n}): ReactNode {\n const play = useSound();\n const [stars, setStars] = useState<Star[]>([]);\n const [score, setScore] = useState(0);\n const [leftMs, setLeftMs] = useState(ROUND_MS);\n const [pops, setPops] = useState<\n Array<{ id: number; x: number; y: number; value: number }>\n >([]);\n const scoreRef = useRef(0);\n const nextId = useRef(1);\n const finished = useRef(false);\n // Latest callback without restarting the round when the parent re-renders.\n const finish = useRef(onFinish);\n finish.current = onFinish;\n\n useEffect(() => {\n const started = performance.now();\n const spawn = setInterval(() => {\n const now = performance.now();\n setStars((list) => [\n ...list.filter((s) => now - s.bornAt < LIFE_MS),\n {\n id: nextId.current++,\n x: 8 + Math.random() * 78,\n y: 10 + Math.random() * 72,\n golden: Math.random() < 0.15,\n bornAt: now,\n },\n ]);\n }, SPAWN_MS);\n const clock = setInterval(() => {\n const left = Math.max(0, ROUND_MS - (performance.now() - started));\n setLeftMs(left);\n if (left === 0 && !finished.current) {\n finished.current = true;\n clearInterval(spawn);\n clearInterval(clock);\n finish.current(scoreRef.current);\n }\n }, 100);\n return () => {\n clearInterval(spawn);\n clearInterval(clock);\n };\n }, []);\n\n const hit = (star: Star) => {\n const value = star.golden ? 3 : 1;\n play(star.golden ? \"coin\" : \"countTick\");\n scoreRef.current += value;\n setScore(scoreRef.current);\n setStars((list) => list.filter((s) => s.id !== star.id));\n const popID = nextId.current++;\n setPops((list) => [...list, { id: popID, x: star.x, y: star.y, value }]);\n setTimeout(\n () => setPops((list) => list.filter((p) => p.id !== popID)),\n 600,\n );\n };\n\n return (\n <div\n style={{\n ...panel,\n position: \"relative\",\n height: `min(${height}px, 70vh)`,\n minHeight: 300,\n overflow: \"hidden\",\n // The playfield: the theme's screen colours, lighter in the middle.\n background: `radial-gradient(circle at 50% 30%, color-mix(in srgb, ${v.bgTop} 75%, #fff) 0%, ${v.bgTop} 45%, ${v.bgBottom} 100%)`,\n touchAction: \"manipulation\",\n userSelect: \"none\",\n }}\n >\n <div\n style={{\n position: \"absolute\",\n top: 10,\n left: 12,\n right: 12,\n display: \"flex\",\n justifyContent: \"space-between\",\n zIndex: 2,\n }}\n >\n <span style={{ ...outlined, fontSize: 18 }}>\n {t(\"score\")}: {score}\n </span>\n <span\n style={{\n ...outlined,\n fontSize: 18,\n color: leftMs < 4000 ? v.red : v.text,\n }}\n >\n {t(\"timeLeft\")}: {Math.ceil(leftMs / 1000)}\n </span>\n </div>\n <div\n style={{\n position: \"absolute\",\n top: 40,\n left: 0,\n right: 0,\n textAlign: \"center\",\n ...outlined,\n fontSize: 14,\n color: v.textDim,\n opacity: score === 0 ? 1 : 0,\n transition: \"opacity .4s\",\n }}\n >\n {t(\"tapStars\")}\n </div>\n {stars.map((s) => (\n <button\n key={s.id}\n type=\"button\"\n aria-label=\"star\"\n onPointerDown={() => hit(s)}\n style={{\n position: \"absolute\",\n left: `${s.x}%`,\n top: `${s.y}%`,\n width: 56,\n height: 56,\n marginLeft: -28,\n marginTop: -28,\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: \"pointer\",\n animation: `idos-star ${v.normal} ease-out`,\n }}\n >\n <Icon\n glyph=\"star\"\n size={s.golden ? 56 : 44}\n color={s.golden ? \"#ffe066\" : \"#fff27a\"}\n />\n </button>\n ))}\n {pops.map((p) => (\n <span\n key={p.id}\n style={{\n position: \"absolute\",\n left: `${p.x}%`,\n top: `${p.y}%`,\n ...outlined,\n fontSize: 22,\n color: v.gold,\n pointerEvents: \"none\",\n animation: \"idos-rise-in .6s ease reverse\",\n }}\n >\n +{p.value}\n </span>\n ))}\n <div\n style={{\n position: \"absolute\",\n bottom: 10,\n left: 0,\n right: 0,\n display: \"flex\",\n justifyContent: \"center\",\n }}\n >\n <Button tone=\"grey\" size=\"sm\" onClick={onCancel}>\n ✕\n </Button>\n </div>\n </div>\n );\n}\n"
|
|
51
|
-
},
|
|
52
|
-
{
|
|
53
|
-
"path": "MiniGamePanel.tsx",
|
|
54
|
-
"content": "import {\n useCallback,\n useState,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport type { IDosGamesClient } from \"@idosgames/core\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Icon,\n configSection,\n errorText,\n formatAmount,\n outlined,\n panel,\n screenBackground,\n useCatalog,\n useGameLayout,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { MiniGame } from \"./MiniGame\";\nimport {\n GAMES_PLAYED_METRIC,\n PLAY_ROUND_ACTION,\n ROUND_STATUS,\n eventsCountingRounds,\n pickLeaderboard,\n questsCountGames,\n seasonChainsForRounds,\n type LeaderboardSection,\n type QuestSection,\n type SeasonSection,\n type TimedEventSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// The mini-game's mode — the whole screen while it is on (the lobby's \"Play\" starts it, the base's\n// round button top-left returns to the lobby). A round submits its score to the title's leaderboard,\n// reports itself to quests that count rounds, and feeds the events and seasons that pay for a round;\n// the table itself is the leaderboard module's screen (\"Leaderboard\" opens it through ctx.features).\n\nexport function makeMiniGamePanel(features: FeatureRegistry): ComponentType {\n return function MiniGamePanel(): ReactNode {\n return (\n <div\n className=\"idos-scroll idos-screen idos-fit\"\n style={{\n position: \"absolute\",\n inset: 0,\n overflowY: \"auto\",\n background: screenBackground,\n fontFamily: v.font,\n color: v.text,\n // Room for the base's \"To the lobby\" button in the top-left corner.\n padding: \"64px 14px 24px\",\n pointerEvents: \"auto\",\n }}\n >\n {/* the field takes a PC's width too (the stars sit at percentages of it) */}\n <div style={{ maxWidth: 1120, margin: \"0 auto\" }}>\n <Round features={features} />\n </div>\n </div>\n );\n };\n}\n\nfunction Round({ features }: { features: FeatureRegistry }): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const layout = useGameLayout();\n const [playing, setPlaying] = useState(false);\n const [result, setResult] = useState<number | null>(null);\n const [bonus, setBonus] = useState<string[]>([]);\n const catalog = useCatalog();\n const board = pickLeaderboard(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n\n const finish = useCallback(\n async (score: number) => {\n setPlaying(false);\n setResult(score);\n kit.play(score > 0 ? \"levelUp\" : \"notify\");\n if (board && score > 0) {\n const res = await client.leaderboard.submitScore(\n board.LeaderboardID,\n score,\n );\n if (!res.ok) toast(errorText(res.error), \"error\");\n }\n // One round played — only if a quest counts rounds (otherwise the server refuses the metric).\n if (questsCountGames(configSection<QuestSection>(client, \"Quest\")))\n await client.quest.addQuestProgress(GAMES_PLAYED_METRIC, 1);\n if (score > 0) setBonus(await rewardRound(client, catalog.localize));\n },\n [client, board, toast, kit, catalog],\n );\n\n if (playing)\n return (\n <MiniGame\n height={layout === \"desktop\" ? 560 : layout === \"tablet\" ? 480 : 380}\n onFinish={(s) => void finish(s)}\n onCancel={() => setPlaying(false)}\n />\n );\n\n return (\n <div\n className=\"idos-shine\"\n style={{\n ...panel,\n padding: 20,\n display: \"grid\",\n gap: 12,\n justifyItems: \"center\",\n background: `linear-gradient(180deg, color-mix(in srgb, ${v.blue} 30%, ${v.panel}) 0%, ${v.panelDeep} 100%)`,\n maxWidth: 520,\n margin: \"0 auto\",\n }}\n >\n <div className=\"idos-bounce\">\n <Icon glyph=\"star\" size={72} />\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 15,\n textAlign: \"center\",\n color: v.textDim,\n }}\n >\n {t(\"best\")}\n </div>\n {result !== null ? (\n <div\n style={{\n ...outlined,\n fontSize: 18,\n color: v.gold,\n animation: `idos-pop-in ${v.slow} ease-out both`,\n }}\n >\n {t(\"yourScore\")}: {formatAmount(result)}\n {result > 0 && board ? (\n <div\n style={{ fontSize: 13, color: v.textDim, textAlign: \"center\" }}\n >\n {t(\"submitted\")}\n </div>\n ) : null}\n {bonus.map((line) => (\n <div\n key={line}\n style={{ fontSize: 14, color: v.green, textAlign: \"center\" }}\n >\n {line}\n </div>\n ))}\n </div>\n ) : null}\n <Button\n attract\n size=\"lg\"\n style={{ minWidth: 180 }}\n onClick={() => {\n setResult(null);\n setBonus([]);\n setPlaying(true);\n }}\n data-tutorial-anchor=\"play:start\"\n >\n ▶ {result === null ? t(\"play\") : t(\"playAgain\")}\n </Button>\n {board && features.get(\"leaderboard\")?.available ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() =>\n features.open(\"leaderboard\", {\n leaderboardID: board.LeaderboardID,\n })\n }\n >\n <Icon glyph=\"trophy\" size={18} /> {t(\"toTable\")}\n </Button>\n ) : null}\n </div>\n );\n}\n\n/**\n * A round's share of the title's live-ops: tokens of every active event that pays for a round (a\n * client-safe CustomAction, capped per day by the event) and status in every season chain open to\n * clients. Returns what was granted, for the result card. Best-effort: a refusal (a cap reached,\n * no active season) just grants nothing.\n */\nasync function rewardRound(\n client: IDosGamesClient,\n localize: (text: string | null | undefined) => string,\n): Promise<string[]> {\n const lines: string[] = [];\n const eventIDs = eventsCountingRounds(\n configSection<TimedEventSection>(client, \"TimedEvent\"),\n );\n if (eventIDs.length > 0) {\n const active = await client.timedEvent.getActiveEvents();\n for (const e of active.ok ? (active.data.ActiveEvents ?? []) : []) {\n if (\n !e.TimedEventID ||\n !eventIDs.includes(e.TimedEventID) ||\n e.CanEarn === false ||\n !e.Type\n )\n continue;\n const res = await client.timedEvent.grantTokens(\n e.Type,\n e.TimedEventID,\n \"CustomAction\",\n undefined,\n undefined,\n { ActionName: PLAY_ROUND_ACTION },\n );\n const op = res.ok\n ? (res.data.Grant?.Standard?.EventTokens?.[0] as\n { Applied?: number; Amount?: number } | undefined)\n : undefined;\n const amount = Number(op?.Applied ?? op?.Amount ?? 0);\n if (amount > 0)\n lines.push(\n `+${formatAmount(amount)} ${localize(e.Content?.Token?.DisplayName ?? e.Content?.DisplayName ?? e.TimedEventID)}`,\n );\n }\n }\n for (const chain of seasonChainsForRounds(\n configSection<SeasonSection>(client, \"Season\"),\n )) {\n const res = await client.season.grantStatusTokens(chain, ROUND_STATUS);\n if (res.ok && Number(res.data.AmountGranted ?? 0) > 0)\n lines.push(\n `+${formatAmount(Number(res.data.AmountGranted))} ${t(\"seasonStatus\")}`,\n );\n }\n return lines;\n}\n"
|
|
55
|
-
},
|
|
56
|
-
{
|
|
57
|
-
"path": "model.ts",
|
|
58
|
-
"content": "// Pure helpers of the mini-game — covered by model.test.ts.\n\n/** Metric the mini-game reports once per finished round. A quest counts it only if it asks for it. */\nexport const GAMES_PLAYED_METRIC = \"games_played\";\n\n/** Leaderboard the mini-game submits to when the title has it; otherwise the first enabled one. */\nexport const PREFERRED_LEADERBOARD = \"weekly_score\";\n\nexport interface LeaderboardSection {\n Definitions?: Record<\n string,\n {\n LeaderboardID?: string;\n DisplayName?: string | null;\n IsEnabled?: boolean | null;\n ScoreSources?: Array<{ SourceType?: string | null } | null> | null;\n } | null\n > | null;\n}\n\n/**\n * The leaderboard the mini-game plays for: the preferred id if enabled, else the first enabled one.\n * Only a board that takes a score from the game — one with a `ClientScore` score source: the server\n * refuses a submitted score into any other board (one it fills itself, or one not set up yet).\n */\nexport function pickLeaderboard(\n section: LeaderboardSection | undefined,\n): { LeaderboardID: string; DisplayName?: string | null } | null {\n const defs = Object.entries(section?.Definitions ?? {})\n .map(([id, def]) =>\n def ? { ...def, LeaderboardID: def.LeaderboardID ?? id } : null,\n )\n .filter(\n (\n d,\n ): d is {\n LeaderboardID: string;\n DisplayName?: string | null;\n IsEnabled?: boolean | null;\n } => d !== null && d.IsEnabled !== false,\n )\n .filter((d) =>\n (\n (\n d as {\n ScoreSources?: Array<{ SourceType?: string | null } | null> | null;\n }\n ).ScoreSources ?? []\n ).some((s) => s?.SourceType === \"ClientScore\"),\n );\n return (\n defs.find((d) => d.LeaderboardID === PREFERRED_LEADERBOARD) ??\n defs.sort((a, b) => a.LeaderboardID.localeCompare(b.LeaderboardID))[0] ??\n null\n );\n}\n\nexport interface QuestSection {\n Quests?: Record<\n string,\n {\n Objectives?: Record<\n string,\n { MetricID?: string | null; Source?: string } | null\n > | null;\n } | null\n > | null;\n}\n\n/** Whether some quest counts the mini-game's rounds — only then is the metric worth reporting. */\nexport function questsCountGames(section: QuestSection | undefined): boolean {\n return Object.values(section?.Quests ?? {}).some((q) =>\n Object.values(q?.Objectives ?? {}).some(\n (o) =>\n o?.MetricID === GAMES_PLAYED_METRIC &&\n (o.Source ?? \"ClientApi\") === \"ClientApi\",\n ),\n );\n}\n\n// ── A round feeds the title's events and seasons ─────────────────────────────────────────────\n// Found in the config, so the mini-game needs neither the events nor the season module installed.\n\n/** The CustomAction a round of the mini-game reports to timed events. */\nexport const PLAY_ROUND_ACTION = \"play_round\";\n/** Status a round grants to every season chain open to clients. */\nexport const ROUND_STATUS = 10;\n\nexport interface TimedEventSection {\n Definitions?: Record<\n string,\n {\n Content?: EventContentView | null;\n Events?: Array<{ Content?: EventContentView | null } | null> | null;\n } | null\n > | null;\n}\ntype EventContentView = {\n TokenSources?: Array<{\n SourceType?: string | null;\n Params?: Record<string, string> | null;\n } | null> | null;\n};\n\nfunction countsRounds(content: EventContentView | null | undefined): boolean {\n return (content?.TokenSources ?? []).some(\n (s) =>\n s?.SourceType === \"CustomAction\" &&\n s.Params?.ActionName === PLAY_ROUND_ACTION,\n );\n}\n\n/** Events (by id) whose token sources pay for a round — own content or any phase of a chain. */\nexport function eventsCountingRounds(\n section: TimedEventSection | undefined,\n): string[] {\n return Object.entries(section?.Definitions ?? {})\n .filter(\n ([, d]) =>\n d &&\n (countsRounds(d.Content) ||\n (d.Events ?? []).some((p) => countsRounds(p?.Content))),\n )\n .map(([id]) => id);\n}\n\nexport interface SeasonSection {\n Chains?: Record<\n string,\n { GrantTokensAccessMode?: string | null } | null\n > | null;\n}\n\n/**\n * Season chains the client may grant status to. Only an explicit \"Both\" / \"ClientOnly\" opens a\n * chain: the engine's default is ServerOnly, so a missing field means \"server only\" too.\n */\nexport function seasonChainsForRounds(\n section: SeasonSection | undefined,\n): string[] {\n return Object.entries(section?.Chains ?? {})\n .filter(\n ([, c]) =>\n c?.GrantTokensAccessMode === \"Both\" ||\n c?.GrantTokensAccessMode === \"ClientOnly\",\n )\n .map(([id]) => id);\n}\n"
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
"path": "module.ts",
|
|
62
|
-
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { makeMiniGamePanel } from \"./MiniGamePanel\";\nimport { t } from \"./i18n\";\n\n// The smallest complete game module: a mode of its own (a route + one full-screen panel, no scene)\n// that the base's lobby offers as \"Play\". It is what a new title gets when no other game fits the idea\n// — the placeholder the real game replaces, with the same contract: register a route, draw the mode,\n// and leave the lobby, balances and wallet to the base.\nexport const miniGameModule: Module = defineModule({\n id: \"mini-game\",\n meta: {\n name: \"Mini-game\",\n type: \"game\",\n genre: \"casual\",\n engine: \"dom\",\n },\n setup(ctx) {\n ctx.registerRoute({ id: \"mini-game\", label: t(\"title\"), icon: \"⭐\" });\n ctx.registerPanel({\n id: \"round\",\n slot: \"overlay\",\n component: makeMiniGamePanel(ctx.features),\n });\n\n // The round is ordinary React in the DOM: an agent reads it and presses its buttons like a person.\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", mode: ctx.modes.current() }),\n describeActions: {},\n });\n },\n});\n"
|
|
63
|
-
},
|
|
64
|
-
{
|
|
65
|
-
"path": "module.meta.json",
|
|
66
|
-
"content": "{\n \"id\": \"mini-game\",\n \"type\": \"template\",\n \"summary\": \"The smallest complete game: a 15-second 'catch the stars' round launched from the lobby's Play button, with a leaderboard score.\",\n \"description\": \"A tiny game of its own mode (React/DOM, no engine): tap as many stars as you can in 15 seconds, gold stars are worth 3. The score goes to the title's leaderboard, and a round counts for quests that ask for games_played, for timed events that pay for the 'play_round' action and for season chains open to clients. The platform gives it to a new title when no other game fits the idea — it shows where a game plugs into the base (sign-in → lobby → Play) and is meant to be replaced by the real game. No demo settings of its own: it plays on the base's world.\",\n \"provides\": [\n \"a small tap game launched from the lobby\",\n \"score submitted to the leaderboard\",\n \"rounds counted by quests, events and the season\",\n \"placeholder game to replace with the real one\"\n ],\n \"tags\": [\"starter\", \"mini-game\", \"casual\", \"tap\", \"leaderboard\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.4\"\n}\n"
|
|
67
|
-
}
|
|
68
|
-
]
|
|
69
|
-
}
|