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

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 }
      ]
    }
  ]
}