# totallynotfun — build a party game This is the whole contract. A game is one directory with four files, written against `@totallynotfun/sdk`, run on your laptop with `npx totallynotfun play`, published by pull request. If it is not on this page it is not part of the contract; do not guess it, ask. ## 1. WHERE THE DIRECTORY GOES — read this before you write a line There are exactly two correct locations, and which one you are in depends on what you cloned: ``` games// ← the public repo (totallynotfun-games): repo root lab/games// ← the platform monorepo: under lab/, never anywhere else ``` **If you can see a `packages/` directory, you are in the monorepo, and your game goes in `lab/games//`.** Create that directory; it is where the reference games already live. Two things follow, and both are hard: - **`packages/server/src/games/` is NOT the lab.** It is the platform's own built-in catalogue: a different contract, a different registry, six manual touch points. A game written there is not a lab game, cannot be submitted, and will not load in a lab room. Neither is `packages/controller`, nor any plugin registry, nor any dispatch branch — the lab has none of those. - **Nothing outside your own `lab/games//` may be edited.** Not a shared file, not a check, not a counts or ratchet JSON, not another game. CI refuses a pull request that touches any path outside one game directory before it runs a single line of your code. An existing game elsewhere in the tree with a similar name is not your game and is not a starting point. Copy `lab/games/hello-world/` instead (§10). ## 1b. What a game provides ``` / manifest.ts id, name, author, players, controls, how to play, tutorial server.ts init / onInput / tick / isGameOver / getResults / getPlayerState (pure) Controls.tsx the phone — a React component; props: state, send, player Renderer.tsx the TV — a React component; props: state, players Spectator.tsx optional — a late joiner's phone; props as Renderer checks/ optional — the game's own harness (a template is provided) ``` - `` is kebab-case, unique in the repository, and equals `manifest.id`. - Every file imports from `@totallynotfun/sdk` and from the game's own directory. Nothing else. No `package.json`, no lockfile, no scripts, no other dependency. CI refuses the pull request before it runs anything. - `server.ts` is pure: state in, state out. It keeps nothing outside the value it returns. ## 2. manifest.ts — every field, one line each ```ts import type { Manifest } from "@totallynotfun/sdk"; // `satisfies`, NOT `: Manifest`. The annotation widens `hiddenInfo` to // `boolean`, and the compiler then cannot tell which rule to hold you to — // it asks a public game for projections it does not need. `satisfies` // checks the same shape and keeps the literal. export const manifest = { protocolVersion: 1, id: "hello-world", /* ... */ } satisfies Manifest; ``` `Manifest` has these fields and no others: - `protocolVersion` — the contract version this game targets. `1`. - `id` — kebab-case; the directory name. - `name` — shown on the card and the TV. - `author` — shown verbatim on the card and in the lobby. The only credit. - `description` — one or two sentences for the card. - `category` — `"party" | "trivia" | "strategy" | "skill"`. - `tags` — optional free words. Not used for filtering. - `minPlayers`, `maxPlayers` — the room refuses to start outside this range. - `controls` — a `ControllerLayout`: `{ joystick: boolean; actionButton: boolean; actionButtonLabel?: string; inputs?: ControllerInput[] }`. What the phone declares; the automatic tutorial is drawn from it. Each `ControllerInput` is one declared control — a `joystick`, `button`, `dpad`, `touchpad`, `tilt`, `text-input` or `choice` — with an `id` and a position. - `hiddenInfo` — `true` if any player holds state others must not see. **Supported — build the hidden-information game if that is the game.** `true` makes BOTH projections REQUIRED: `getPlayerState` and `getSpectatorState` (§3). The registry refuses a manifest that declares `hiddenInfo` with only one of them defined, and a room refuses to start it, so one without the other is not a partial implementation — it is a game nobody can play. With `true` the TV is sent `getSpectatorState(state)` and never the raw state, each phone is sent `getPlayerState(state, id)`, and the leak scan in §8 reads both. With `false` everyone is sent the whole state and neither projection is needed. Declare it as a literal `true`/`false`, not a computed boolean: the compiler checks this rule where the manifest meets the server, and it can only check a literal. - `spectatorView` — optional `"scores" | "full" | "none"`: what a late joiner's phone shows. - `roles` — optional `GameRoleInfo[]` for asymmetric games: `{ id, name, color?, maxCount? }`. - `howToPlay` — `HowToPlay`: `{ objective: string; steps: string[] }`. One line and three to five imperative steps, shown on the TV before the game. REQUIRED. - `tutorial` — a `GameTutorial`, or `"auto"` to derive one from `controls`. REQUIRED. What is gated is exactly this: `howToPlay` and `tutorial` are required fields, so a missing one fails types. A coach hint or practice round on a non-obvious control is strongly recommended and is read at graduation, not gated — unintuitive pieces derail a room. ## 3. server.ts — the reducer, one line each ```ts import type { GameServer, Player, Ctx, InputEvent } from "@totallynotfun/sdk"; export const server: GameServer = { init, onInput, tick, isGameOver, getResults }; ``` A `GameServer` is these functions. Each receives a `Ctx` (§4) as its last argument except `isGameOver` and `getResults`, which are pure reads. - `init(players, ctx)` → `State` — the starting state for these players. `players` is `Player[]`: `{ id, name, color, connected, isHost, role, gameRole? }` and a few cosmetic fields you can ignore. - `onInput(state, playerId, input, ctx)` → `State` — one sanitised input (§6) from one phone. Return the new state; ignore what you do not expect. - `tick(state, deltaMs, ctx)` → `State` — on every tick of the room's loop, designed at sixty a second. Do not depend on the cadence: integrate with `deltaMs`. Large gaps are capped so a sleeping laptop does not jump (the cap is the runtime's). A turn-based game returns `state` unchanged. - `isGameOver(state)` → `boolean` — asked every tick. `true` ends the game. - `getResults(state)` → `GameResults` — once, when over: `{ scores, winner, placements, outcome?, headline?, valueLabel? }`. `outcome` is a `ResultsOutcome`: `{ kind: "ranked" }`, `{ kind: "team", winningTeamId, teams }`, `{ kind: "coop", won }` or `{ kind: "solo", winnerId }`. - `getPlayerState(state, playerId)` → `object` — what THIS phone may see. REQUIRED when `hiddenInfo` is true (§2). Optional otherwise. - `getSpectatorState(state)` → `object` — the view for anyone who is not a seated player: the TV under `hiddenInfo`, and a spectator's phone always. REQUIRED when `hiddenInfo` is true (§2). Optional otherwise. Who receives what: the TV receives the whole `State` on every tick it changes — unless `hiddenInfo` is true, in which case it receives `getSpectatorState(state)` and never the raw state. Each phone receives `getPlayerState(state, id)` when you define it, otherwise the whole `State`, every sixth tick (ten times a second); a spectator's phone receives `getSpectatorState(state)` every thirtieth tick when defined. **Hidden information works** (§2) — declare it, write both projections, and the leak scan in §8 reads them. State rules, all gated: - JSON-serialisable: plain objects, arrays, numbers, strings, booleans, `null`. No `Map`, `Set`, class instances, functions, `Date`, `bigint`. - Under the per-tick size cap: **256 KB** of serialized state. Past it the sandbox stops the game with `oversize` (§4). Design for a few kilobytes anyway — bandwidth is the box's binding constraint. - Return a new object when something changed. The loop serialises to decide whether to send, so an unchanged state costs nothing on the wire. ## 4. ctx — and why it exists - `ctx.rng()` → a number in `[0, 1)`. Seeded per room. Use it wherever you would have used `Math.random`. - `ctx.now()` → milliseconds on the tick clock, starting at `0` when the game starts. Use it wherever you would have used `Date.now`. - `ctx.connected` → `ReadonlySet` of player ids whose phone is attached right now. A player absent from it is offline or gone. Why (the sandbox): your reducer runs inside a JavaScript interpreter compiled to WebAssembly, one per room, with **32 MB of memory** and a **50 ms deadline on every call**. Inside it `Math.random` and `Date.now` are replaced by `ctx.rng` and `ctx.now`, so two rooms fed the same inputs play the same game. That is how a reported game is replayed and how a stalled one is stopped. The server lint fails a `server.ts` that names `Math.random` or `Date.now`. Nothing else is stable across replays. When the sandbox stops a game — a throw, running out of memory, a call past its deadline, an oversize state, or a return that does not serialise — the room receives an `error` with `{ reason, entry, message }` where `reason` is one of `"throw" | "deadline" | "memory" | "oversize" | "bad-return"` and `entry` names the function, and the room returns to the lobby. The results say the game was stopped; nobody wins. ## 5. Controls.tsx and Renderer.tsx — the phone and the TV Both are ordinary React components rendered on the lab's origin. They are not sandboxed; the hard client rules in §8 are what stands between them and the visitor's browser, and they are enforced before the code is served. - `Controls` receives `state` (what §3 says a phone gets), `send` (queue one input, §6) and `player` (this phone's `Player`). - `Renderer` receives `state` and `players`. - `Spectator`, if present, receives what `Renderer` receives. From the SDK (§12 is the authoritative list, generated from the package): phone primitives `TapButton`, `BidPad`, `PlayingCard`, `DrawCanvas`, `VirtualJoystick`, `CoachHint`, `Onboarding`; TV parts `PlayingCardTV`, `safeRender`, `useNarration`; motion `injectKeyframes`, `DUR`, `EASE`; the room's `PLAYER_COLORS`; card, poker and chess helpers; `autoTvTutorial`. These exist in the platform today; the SDK re-exports them. Every control a phone shows is at least 44pt tall (advisory, §9). Colours, fonts, canvas, sound: your call. An emoji game that is fun beats a polished game that is not. Taste is advisory here (§9). ## 6. Input — the six kinds and their caps `send` takes one `InputEvent`. The server sanitises every message before your reducer sees it, with these exact numbers. A value past a clamp is clamped or the whole message is dropped, as stated, silently. Design inside them; if a control needs a wider range, scale it on the phone. | kind | fields | what the server does | |---|---|---| | `move` | `x`, `y` | each clamped to -1..1; a non-finite value becomes 0, so a quiet stick reads centred and is never latched | | `position` | `position` | clamped to 0..1; a non-finite value drops the message and the last good position holds | | `action` | `action`, `angle?`, `aimX?`, `aimY?`, `charge?` | an `action` longer than 256 chars drops the message; `angle` any finite number; `aimX`/`aimY` clamped to -1..1; `charge` clamped to 0..1; a malformed modifier arrives as `undefined` and the verb still arrives | | `choice` | `choice` | must be an integer or the message is dropped — never coerced to 0, which would commit a move the player never made. Not range-checked: bounds-check the index yourself | | `text` | `text` | cut to 200 chars; a non-string becomes `""` | | `draw` | `strokes` | at most 120 strokes per message; at most 512 numbers (256 x,y pairs) per stroke; 20,000 numbers per message, whole strokes dropped once the budget is spent; coordinates clamped to 0..1 and rounded to 1/10000; width clamped 0.001..0.08 with default 0.018; `color` must be `#rgb` or `#rrggbb` or it becomes `#000000`; rate: a burst of 5 messages, then one more every 50 ms, extras dropped | Inputs are queued and drained in order at the next tick; a phone that queues more than the room's per-tick budget has the surplus dropped. The precedent for this table: an aim value sent outside -1..1 arrived clamped and a two-phone game read a wrong angle for weeks with nothing red anywhere. ## 7. The four commands — all four are built, and two more gates besides ``` npx totallynotfun create scaffold the game directory from the template npx totallynotfun check the hard gates (§8), then the advisory warnings (§9) npx totallynotfun play a room on this machine: TV URL + a QR for the phones npx totallynotfun submit prove it is green, then print the steps to open the PR ``` `tnf` is the alias: `tnf create `, `tnf check`, `tnf play`, `tnf submit`. `check` exits 0 only when every hard gate passes; read the first failure, fix it, run it again. **This is the command CI runs on your pull request** — same binary, same steps, same exit code. Its five steps, in order, each reported even when an earlier one failed: **boundary** (§8's two lints, self-tested on a hostile fixture first), **types**, **consumer** (your directory built and run in a project that has never seen this repository), **harness** (your `checks/` — exit code AND assertion count), **play** (§8's termination rule, in a real room over real sockets). Flags: `--bail`, `--json`, `--skip-play` (CI does not skip it), `--timeout ` to move the play bound §8 states. `submit` re-runs all of that, then prints the pull-request steps; it sends and opens nothing. `play` binds the runtime on your LAN, prints the TV address and a QR of the phone address, adds that host to the origin allowlist for this run, and says one sentence about the firewall prompt; if the machine has no LAN address it says so instead of printing `localhost`. Flags: `--host`, `--port`, `--game `. **In the platform monorepo**, the same two lints run over every game under `lab/games/` as npm scripts, and these are the two a maintainer will quote back at you: ``` npm run check:sdk-boundary the import and purity lint (§8), every lab game npm run check:lab-consumer every lab game built and run in a clean project ``` `check:lab-consumer` **exits 1 with zero failed assertions** while the tree is dirty or your game is uncommitted — its coverage ratchet will not bank a floor it cannot reproduce, which for a new game is always. Read the line it prints: `N passed, 0 failed` is a pass whatever the exit code says. Do not "fix" it by editing the counts file; editing any ratchet or counts JSON is one of the paths CI refuses outright (§1). ## 8. The rules the gates enforce — hard, because they protect other people The two glob paths below are written for the public repo; in the monorepo the same scan reads `lab/games/**` (§1). - **Client lint** over `games/**/*.tsx`: no `fetch`, `XMLHttpRequest`, `WebSocket`, `EventSource`, `sendBeacon`; no `localStorage`, `sessionStorage`, `indexedDB`, `document.cookie`; no `
`, external `href`, `window.open`, `location` assignment; no `eval`, `new Function`, URL `import()`, `