The window.__game convention
Any three.js, React Three Fiber or WebGL page can be opened, looked at and driven without it: game_open, game_look, game_observe (which falls back to the page's named three.js objects), game_eval, game_act with keys, mouse and touch, game_feel, game_perf and game_optimize work on a page that knows nothing about the harness. To check a game's rules, the game also exposes window.__game, and game_check needs all four of its functions.
Once the page has loaded, it exposes:
window.__game = {
describe(), // a short description: name, controls, units
observe(), // the state, exactly the keys of game-contract.json's schema, as plain JSON
act(input), // performs one input object: { key, down? } or { action, ...args }
setState(partial), // sets any of the schema's keys; keys left out keep their values
};
observe, look, perf and optimize work on any three.js page; game_check needs all four functions.
Rules
- The game starts playing as soon as the page loads: no title screen and no click to start.
- Time. The harness steps frames one at a time, 1/60 s each, on a fake clock (
requestAnimationFrame,performance.now,Date, timers). Advance the game by each frame's elapsed time, or a fixed 1/60 s per frame, and never by more than 0.1 s in one frame. Count the first frame as one frame: the clock jumps forward when the page finishes loading, so time read before the first frame is not game time. Speeds are per second of game time. Never read real time some other way. - Randomness. Use
Math.random; the harness seeds it, so a check sees the same game every time. - setState takes effect at once: the next
observe()returns the new values, and the game's own start-up must not undo them, even whensetStateruns before the first frame. Objects and lists are replaced whole, with the same fieldsobserve()returns. - observe() returns plain JSON (numbers, booleans, strings, null, objects, arrays), never three.js objects. Lists keep their order: new items at the end, removed items dropped.
- Keys listen on
windowand matchevent.key. A key can go down and up within one frame, so one-shot controls (turn, jump, fire) act onkeydown; held controls (move, steer) last fromkeydowntokeyup. Route real key events andact({ key })through the same function. - Names. Give meshes a
name(player,enemy3,coin):game_looklists what is on screen by name.
templates/plain.html is a complete small game in this shape. In React, keep the game state in a plain module outside components (as templates/r3f.tsx does), so setState and observe never wait for a render, and set window.__game in the Canvas's onCreated: the harness starts stepping frames once __game exists.
The harness sends keys, mouse and touch as real browser events, so the game's own listeners receive them. An { action, ...args } object reaches __game.act whole.
The contract file: game-contract.json
A game contract is what the user's prompt promises, as keypoints the harness can check: each sets a state through setState, sends a bounded list of inputs, steps a fixed number of frames and then tests predicates over observe(). Only game_contract writes it. It gives a fresh subagent, one that has seen no game code, the format below and the user's prompt; the subagent drafts the contract and the harness validates it and saves it as <project>/game-contract.json. game_check runs it against the open game.
Fields
| Field | Type | Meaning |
|---|---|---|
v |
1 |
The format version. |
prompt |
string | The user's prompt, verbatim, so the contract says where each rule comes from. |
issued |
string | An ISO time, stamped by the harness when it saves the file. The subagent leaves it out. See "How issued works". |
schema |
object | Mirrors what observe() returns. Leaves are strings of the form "type: meaning" ("number: column 0..19"), nested objects are objects, and an array holds one item shape ([{ "x": "number", "y": "number" }]). It is also the contract for the game code: observe() returns exactly these keys. |
keypoints |
Keypoint[] | A non-empty list; the subagent is asked for 8 to 20, one behaviour each, covering every rule the prompt states and its edge cases. |
budget |
object, optional | The budget game_check, game_perf and game_optimize measure against instead of the desktop default; frameMs is the frame time in milliseconds. |
Keypoint
| Field | Type | Meaning |
|---|---|---|
id |
string | Unique within the contract, kebab-case. |
title |
string | What the player would notice, such as "eating food grows the snake by one". |
state |
object | Passed to window.__game.setState() on a freshly loaded page. Every top-level key must be in schema. Set every value the outcome depends on, so no keypoint depends on another or on the starting layout. |
actions |
Action[] | The inputs, in order; the list may be empty. Each advances one frame unless it says frames. |
frames |
number, optional | Frames stepped after the actions, 0 to 600 (ten seconds at 60 fps). 60 when left out. |
expect |
Predicate[] | At least one predicate; all must hold for the keypoint to pass. |
Actions
An action is one object with exactly one of these keys:
type Action =
| { key: string; down?: boolean; frames?: number } // a key by event.key; down and up in one frame unless down is given
| { mouse: "click" | "down" | "up" | "move"; x: number; y: number; button?: string }
| { touch: "start" | "move" | "end"; x: number; y: number }
| { wait: number } // frames to step with no input
| { action: string; [arg: string]: any }; // reaches __game.act whole, with its arguments
Predicates
interface Predicate { path: string; op: Op; value?: unknown }
type Op = "==" | "!=" | "<" | "<=" | ">" | ">=" | "exists";
path is dotted and must resolve in schema: object keys, then on an array an index or length (head.x, body.length, body.0.x). exists takes no value; the other operators compare the observed value with value, and == and != compare deeply, so value can be an object or an array.
How a check runs
game_check runs every keypoint on a freshly reloaded page: setState(state), confirmed in observe() before any frame runs; then each action, one frame each unless it says frames; then frames more frames; then every predicate in expect over observe(). A keypoint ends in one of four outcomes:
| Outcome | Meaning |
|---|---|
launch_failed |
The page did not load or draw a frame. |
state_not_realized |
After setState, observe() did not return those values. |
actions_incomplete |
An input could not be sent or act threw. |
outcome_mismatch |
A predicate did not hold; the line says what was expected and what was observed. |
A failing keypoint saves a picture of the page in the run folder. The result's last line is the frame time the keypoints stepped at, against the budget. When a result ends with "stale", the sources changed while it ran. A hook also runs the check after each edit, in the background.
How issued works
The subagent that writes the contract leaves issued out. When the harness saves the contract it stamps issued with the current time, writes <project>/game-contract.json, and records the project path with a SHA-256 hash of the file in ~/.paprika3d/issued.json (or $PAPRIKA3D_HOME/issued.json), outside the project. The hash is taken over the JSON with its keys sorted at every level, so formatting and key order do not matter. The coding agent is never told the hash.
Each time game_check loads the contract it hashes the file again. A hash that differs from the record is refused with "game-contract.json was changed by hand; run game_contract again with the user's prompt", and a guard hook in the plugin refuses edits to the file as well. To change what the game must do, run game_contract again with the user's new prompt. A file with no record for its project (another machine, or a file under another name such as game-tests.json) loads without the comparison and is only validated.
Example
The contract of the snake fixture, written from the prompt in its prompt field:
{
"v": 1,
"prompt": "Snake on a 20x20 grid. The snake moves one cell per frame and the arrow keys steer it, but it can't reverse into itself. Eating food grows it by one and scores a point. Hitting a wall or its own body kills it. Two portals: entering one puts the snake on the cell just past the other, still moving the same way.",
"issued": "2026-10-11T00:00:00.000Z",
"schema": {
"frame": "number: frames since load",
"score": "number: food eaten",
"alive": "boolean",
"head": { "x": "number: column 0..19", "y": "number: row 0..19, up is +y" },
"body": [{ "x": "number", "y": "number" }],
"food": { "x": "number", "y": "number" },
"portals": [{ "x": "number", "y": "number" }],
"dir": "string: up | down | left | right"
},
"keypoints": [
{
"id": "moves-one-cell-per-frame",
"title": "the snake moves one cell per frame and its body follows",
"state": { "alive": true, "dir": "right", "head": { "x": 3, "y": 3 }, "body": [{ "x": 2, "y": 3 }, { "x": 1, "y": 3 }], "food": { "x": 10, "y": 10 } },
"actions": [],
"frames": 3,
"expect": [
{ "path": "head.x", "op": "==", "value": 6 },
{ "path": "head.y", "op": "==", "value": 3 },
{ "path": "body.0.x", "op": "==", "value": 5 },
{ "path": "body.length", "op": "==", "value": 2 }
]
},
{
"id": "turn-up",
"title": "ArrowUp turns the snake up",
"state": { "alive": true, "dir": "right", "head": { "x": 3, "y": 3 }, "body": [{ "x": 2, "y": 3 }, { "x": 1, "y": 3 }], "food": { "x": 10, "y": 10 } },
"actions": [{ "key": "ArrowUp" }],
"frames": 2,
"expect": [
{ "path": "dir", "op": "==", "value": "up" },
{ "path": "head.x", "op": "==", "value": 3 },
{ "path": "head.y", "op": "==", "value": 6 }
]
},
{
"id": "no-reverse",
"title": "pressing the opposite direction does not reverse the snake",
"state": { "alive": true, "dir": "right", "head": { "x": 3, "y": 3 }, "body": [{ "x": 2, "y": 3 }, { "x": 1, "y": 3 }], "food": { "x": 10, "y": 10 } },
"actions": [{ "key": "ArrowLeft" }],
"frames": 0,
"expect": [
{ "path": "dir", "op": "==", "value": "right" },
{ "path": "head.x", "op": "==", "value": 4 },
{ "path": "alive", "op": "==", "value": true }
]
},
{
"id": "eat-grows",
"title": "eating food grows the snake by one and scores a point",
"state": { "alive": true, "score": 0, "dir": "right", "head": { "x": 3, "y": 3 }, "body": [{ "x": 2, "y": 3 }, { "x": 1, "y": 3 }], "food": { "x": 4, "y": 3 } },
"actions": [],
"frames": 1,
"expect": [
{ "path": "score", "op": "==", "value": 1 },
{ "path": "body.length", "op": "==", "value": 3 },
{ "path": "head.x", "op": "==", "value": 4 },
{ "path": "food", "op": "!=", "value": { "x": 4, "y": 3 } }
]
},
{
"id": "wall-kills",
"title": "running into a wall kills the snake and it stops",
"state": { "alive": true, "dir": "right", "head": { "x": 19, "y": 10 }, "body": [{ "x": 18, "y": 10 }, { "x": 17, "y": 10 }], "food": { "x": 10, "y": 10 } },
"actions": [],
"frames": 3,
"expect": [
{ "path": "alive", "op": "==", "value": false },
{ "path": "head.x", "op": "==", "value": 19 }
]
},
{
"id": "self-collision-kills",
"title": "turning into its own body kills the snake",
"state": { "alive": true, "dir": "left", "head": { "x": 5, "y": 5 }, "body": [{ "x": 6, "y": 5 }, { "x": 6, "y": 6 }, { "x": 5, "y": 6 }, { "x": 4, "y": 6 }], "food": { "x": 10, "y": 10 } },
"actions": [{ "key": "ArrowUp" }],
"frames": 0,
"expect": [
{ "path": "alive", "op": "==", "value": false },
{ "path": "head.y", "op": "==", "value": 5 }
]
},
{
"id": "tail-chase-safe",
"title": "moving onto the cell the tail is leaving is safe",
"state": { "alive": true, "dir": "up", "head": { "x": 5, "y": 5 }, "body": [{ "x": 5, "y": 4 }, { "x": 6, "y": 4 }, { "x": 6, "y": 5 }], "food": { "x": 10, "y": 10 } },
"actions": [{ "key": "ArrowRight" }],
"frames": 0,
"expect": [
{ "path": "alive", "op": "==", "value": true },
{ "path": "head.x", "op": "==", "value": 6 },
{ "path": "head.y", "op": "==", "value": 5 },
{ "path": "body.length", "op": "==", "value": 3 }
]
},
{
"id": "portal-exit",
"title": "entering a portal comes out on the cell just past the other one",
"state": { "alive": true, "dir": "right", "head": { "x": 4, "y": 15 }, "body": [{ "x": 3, "y": 15 }, { "x": 2, "y": 15 }], "food": { "x": 10, "y": 10 }, "portals": [{ "x": 5, "y": 15 }, { "x": 14, "y": 4 }] },
"actions": [],
"frames": 1,
"expect": [
{ "path": "head.x", "op": "==", "value": 15 },
{ "path": "head.y", "op": "==", "value": 4 },
{ "path": "dir", "op": "==", "value": "right" }
]
},
{
"id": "dead-stays-still",
"title": "a dead snake does not move",
"state": { "alive": false, "dir": "right", "head": { "x": 3, "y": 3 }, "body": [{ "x": 2, "y": 3 }, { "x": 1, "y": 3 }], "food": { "x": 10, "y": 10 } },
"actions": [{ "key": "ArrowUp" }],
"frames": 5,
"expect": [
{ "path": "alive", "op": "==", "value": false },
{ "path": "head.x", "op": "==", "value": 3 },
{ "path": "head.y", "op": "==", "value": 3 }
]
}
]
}