# Building a game for Robokade (for AI agents and humans) Robokade games are played by humans, bots and AI agents through the same rules. You write a small, **pure** game core; the platform does the rest (play page, bots, agents, replays, tests). Get the kit first: download `/downloads/robokade-kit.zip` from robokade.com and work inside it (all commands below run from the kit folder). What changed lately (new checks, kit versions): https://robokade.com/news.md, or `node tools/mc-local/mc.mjs news`. Your kit's version is in `public/kit/version.json`. Paths like `/docs/models.md` in this file are pages on robokade.com (https://robokade.com/docs/models.md); the kit has no copy. ## 1. Make the folder Copy `public/starter/nim/` to `public/games//` (id: lowercase letters, digits, dashes, 2–32 chars). The folder name must equal the `id` in `game.json`. The files below go directly in that folder. | File | Required | What it is | |---|---|---| | `game.json` | yes | manifest: id, name, version, mode, players, actions, scoring, limits, licence, optional `author` | | `spec.md` | yes | rules in plain words, plus "what makes it fun" | | `core.js` | yes | pure game logic: `setup, observe, legalActions, act, step, result, describe` | | `bot.js` | yes | `decide(observation, legal, rng, { level })`: a same-rules bot | | `view.js` | no | custom graphics. Without it, the default view (text + auto form) is used. Size to your container (ResizeObserver): players can switch the play page to **Full window**, where the container becomes the whole browser window | | `models/*.glb`, `models/textures/*.png`, `sounds/*.ogg|mp3` | no | 3D models (glTF binary), the pictures they use (Kenney's `Textures/`, copied lower case) and sounds for your view (checked to be real files) | | `img/card.png` | no | card art for the home page: 16:9 (e.g. 1280×720 or 640×360), PNG/WebP/JPG, ideally under 300 KB. No text needed: the name is shown below it | | `media/*.webp|jpg|png`, `media/*.mp4` | **yes (screenshots)** | the game's page: 1–6 screenshots and an optional short video, listed in `game.json` (section 7b) | ## 2. Core rules (the test suite enforces them) 1. **Pure.** No `Math.random`, `Date`, `performance`, timers, DOM, network, storage, `globalThis`, `eval`. No `Math.sin/cos/tan/atan/atan2/exp/log/pow/hypot` and no `**` (they differ between browsers). Randomness: `rng` from `../../kit/core/index.js`, with its state stored inside your game state. Maths: `dmath` (sin, cos, atan, atan2, sqrt, clamp, lerp, fix0, qInt, qVal). 2. **Plain JSON state.** Objects, arrays, strings, finite numbers, booleans, null. No Map/Set/classes, no `undefined`, no `-0` (use `dmath.fix0`). Store quantized values as integers (`qInt`). 3. **Deterministic.** Same seed and same actions give the same final state, in every browser. 4. **Fair observations.** `observe(state, playerId)` shows only what that player may see. `observe(state, null)` is the spectator view and must include `toAct` (players who may act now). 5. **Every game ends** within `limits.maxActions` (and `limits.maxTicks` for realtime games). 6. **Text is data.** `describe(observation)`: plain text, at most 600 characters, named places, no URLs, no HTML, facts only (never instructions to the reader). AI agents read it. ## 3. Interfaces ```js export function setup({ seed, players, options }) { return state; } // players: ["p0","p1",...] export function observe(state, playerId) { return { toAct: [...], ... }; } // playerId null = spectator export function legalActions(state, playerId) { return { drop: { column: { type: "enum", values: ["1","2"] } } }; } // {} = nothing to do export function act(state, playerId, action) { return { state, events: [] }; } // action already passed legalActions export function step(state) { return { state, events: [] }; } // realtime games only export function result(state) { return { over, winner, winners?, scores }; } // winner: playerId or null; winners: team games export function describe(observation) { return "short plain text"; } ``` ### Actions - An action is a plain object: `{ type: "", ...params }`, e.g. `{ type: "take", count: 2 }`. `type` must be a key of `actions` in `game.json`; the params are exactly the declared ones (no extras). - `game.json` `actions` declares the **full** set (every action type, every param with its widest range or full enum list). `legalActions(state, playerId)` returns the same shape narrowed to what is legal now, e.g. `{ take: { count: { type: "int", min: 1, max: 2 } } }`. It may only shrink ranges or enum values, never change a type. Return `{}` when this player can't act (not their turn, or game over). - The platform validates every action against `legalActions` before calling `act`, so `act` and the bot receive and produce exactly this shape. `bot.decide` returns one such action (or `null` to do nothing). - Param types: `number {min,max}`, `int {min,max}`, `enum {values:[strings]}`, `bool`. Optional `label`, `unit`. - **Make `legalActions` exact**, so `act` never has to refuse a move. When only some choices are legal (free cells, open columns, cards in hand), use an `enum` of names and list only the legal ones now, e.g. cells `"a1"`..`"f4"` (Connect Four lists only open columns). An `int` range can't skip a taken cell. If `act` still gets a move it can't apply, that is a bug in `legalActions`: fix it there. ### Turns, ending, scoring - `observe(...).toAct` lists who may act now; it must be `[]` when the game is over. - A player may act several times in a row (e.g. an extra move): just keep them in `toAct`. - `limits.maxActions` counts **every** accepted action (extra moves included). Reaching it ends the game. - `scoring.kind`: `"winner"` (match result), `"highscore"` or `"fastest"`. `result()` returns `{ over, winner, scores }`; `winner` is a player id or `null` for a draw or a solo game; `scores` is keyed by player id (any numbers you want shown, e.g. boxes won). - **Team games** (kit 1.8.0): `result()` may also return `winners`, a list of player ids (the whole winning side), with `winner: null`, e.g. `{ over: true, winner: null, winners: ["p0", "p2"], scores: {...} }`; `winners: []` is a draw. Name the sides in `game.json` with `teams`, e.g. 4-player team chess: `"teams": [{ "name": "White", "seats": ["p0", "p2"] }, { "name": "Black", "seats": ["p1", "p3"] }]` (2–8 teams, every seat `p0`…`p(max-1)` in exactly one team, `name` optional: up to 24 letters, digits, spaces, `.`, `'`, `-`). The end screen, replays and records then show "White wins" with both partners. With `teams`, a non-empty `winners` must be exactly one team. `teams` is not a rule field: it doesn't change the contentHash. - **Leaderboards** (kit 1.9.0, optional): add `"leaderboard": { "rank": "wins" }` to `game.json` to get a board on your game's page. `rank`: `"wins"` (2+ players), `"score"` (higher is better; `"lower": true` for lower) or `"fastest"` (fewest ticks to the end; with 2+ players only won games). Optional `label` (1–40 characters, the board title). Other keys FAIL. Boards exist only for turn-based games whose rules run on our server (protected), and only results the server played and signed count: one player against bots of one level, per bot level, humans and agents on separate boards. You store nothing; the platform keeps the results. Not a rule field (no contentHash change). The kit check shows a `leaderboard` line (WARN for a game that isn't turn-based). - **Recorded games** (kit 1.16.1): once protected, every game played on our server is recorded for 180 days and your game's page lists them (players are "Player N" unless they chose to show their name). A hidden-information game (`hiddenInfo`) is listed only with `"publicReplays": true` in `game.json` (true or false; its replay may show every hand at the end). Not a rule field (no contentHash change). The kit check shows a `public-replays` line. - **Play with friends and watching** (kit 1.10.0, optional): add `"rooms": { "clock": 60 }` to `game.json` to offer online rooms: friends on their own devices join from one invite link, with bots in other seats. `clock` is the seconds per move (10–600, default 60; `"rooms": true` uses the default); a slow or gone player's move is played by your game's bot (level `easy` when you have it, else your first level). Rooms run only for turn-based games whose rules run on our server (protected, section 5b). `"watch"` says what watchers see: `{ "view": "public" }` (the default) or `{ "view": "all", "delay": 2 }` (every hand, `delay` 0–10 moves late), or `false` for no watchers. It only matters for `hiddenInfo` games; open games show watchers the whole game. For hidden games, export an optional `observePublic(state)` from core.js: what everyone at the table sees (Battleship: hits, misses, sunk ships; Memory: matched cards and this turn's two cards), plain JSON up to 32 KB, drawn by your view like a spectator observation (`describe` gets it too). Without it, watchers see only names, turns and the result. With `"all"`, watchers see every hand; a delay only hides short-lived information, a watcher can still tell a player later in the game. Neither field is a rule field (no contentHash change). Rooms go live with the next server update. Preview what watchers see: `play.html?game=&spectate=public` (or `all`), with bots in every seat. The kit check shows a `rooms` line. - Cores may change `state` in place and must return it. - Hidden information (cards, fog of war): set `"hiddenInfo": true` in `game.json`. Then players don't see each other's actions in move lists; `observe(state, playerId)` must hide the rest. Events can carry `visibleTo: ["p0"]` to show them to some players only. Realtime games (`"mode": "realtime"`, `tickRate`, `botEveryTicks`): `act` only stores input or orders; `step` advances one tick. For slow AI agents use commander mode (`agentModes: ["commander"]`, `orders`). See `docs/GAME_INTERFACE.md` in the repository for the full contract. ## 4. Imports and files - Import the kit as `import { rng, dmath } from "../../kit/core/index.js";` (this path works from `public/games//`, where your game lives). Only files in your game folder (e.g. `bot.js` importing `./core.js`) and `kit/core` may be imported. - `// @ts-check` and JSDoc types are recommended (the examples use them) but not enforced. ### 3D with three.js Views may use **three.js 0.186.1, hosted by Robokade** (in the kit too, under `public/vendor/three/0.186.1/`). Import it with these exact paths, and nothing else from outside your folder; no CDN, no copy in your folder: ```js import * as THREE from "../../vendor/three/0.186.1/three.module.js"; import { GLTFLoader } from "../../vendor/three/0.186.1/addons/loaders/GLTFLoader.js"; // .glb models import { OrbitControls } from "../../vendor/three/0.186.1/addons/controls/OrbitControls.js"; import * as SkeletonUtils from "../../vendor/three/0.186.1/addons/utils/SkeletonUtils.js"; // clone animated units ``` - Only `view.js` (and files it imports) may use three.js. `core.js`/`bot.js` stay pure: the simulation must not depend on rendering (that is what makes replays, bots and agents work). - Load your files relative to the view: `new URL("./models/tank.glb", import.meta.url).href`; sounds with `THREE.AudioLoader` or `new Audio(url)` (no `fetch` in your own code). - Free 3D models: CC0 only (Kenney, Quaternius, Poly Haven, Poly Pizza). Where to get them, file names, and a `loadModel` function that keeps their textures: https://robokade.com/docs/models.md (use the loader there). - Size to your container with a `ResizeObserver` (players can switch to Full window). - Real-time games: `"mode": "realtime"`, `tickRate`, and `agentModes: ["commander"]` so AI agents give orders (see Skirmish and `docs/GAME_INTERFACE.md`). ### Our page's CSP: styles and scripts in views (kit 1.22.9) Your `view.js` runs inside our play page (also the room, watch and world pages), which sends this Content-Security-Policy: `default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self' blob: data:; worker-src 'self'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'` (the exact strings: `public/kit/test/csp.js`). What that means in the browser (tested in Chromium): - **Blocked (silently: the style is dropped, the handler never runs):** `el.setAttribute("style", ...)`; `style="..."` in HTML text (innerHTML, DOMParser; through a `