# game/ — the optional game engine add-on
A game is an Exact2 app with a world inside a `canvas`. Write gameplay in Rust and
menus, HUDs and accessible controls in Contract. The simulation and renderer share
one module, loaded after the app's first pixel. Apps without a world carry none of it.
## Optional by construction
`game/Cargo.toml` owns this workspace. It is absent from exact2's root members,
and no core crate depends on an `exact-game*` crate or Rapier. An app opts in by
linking `exact-game` and `exact-game-render` into its **GPU module**, never its
host or data module. The host loads that artifact after first paint when a canvas
needs it. An app whose other screens draw too can give the world a module of its own
(`gpu.modules` in `app.json`, LLP 1009 D6), loaded only when the world's canvas mounts.
Removing the app's game module removes the engine from its bundle.
The root build, test and boot checks stay independent of the engine workspace.
On Windows, build from the generated game's directory in PowerShell:
```powershell
bun exact.mjs windows --run
```
For an older external game, run `bun ../exact2/scripts/exact.mjs new . --update`
once to refresh its runner. From the SDK root, the equivalent is
`bun host/windows/build.mjs my-game` with `EXACT_APP_DIR` naming the game.
The resulting `my-game/dist-windows` directory contains the standalone executable,
game DLL and assets. Keep these files together; the executable resolves assets
relative to itself and needs no web server. `--release` selects the release profile,
and `--run` opens the game. A running copy holds its files open; `--out
`
packages into another directory instead. The initial Windows host uses GPU canvas readback and
CPU UI composition, and reports mean and p50/p95 frame costs when its window closes.
It supports store level 0; native update-store delivery is refused. Use
`bun my-game/proof.mjs windows` for the native gameplay and pixel proof.
For a bounded development window measurement, set `EXACT_AGENT_WINDOW_FRAMES`
to 1–10,000 and optionally `EXACT_AGENT_WINDOW_TAP` to one authored test ID to tap
after first pixel. Production bakes ignore these agent variables.
Asset names have no fixed per-surface count limit. Web loading still bounds
concurrent requests and decoded asset bytes; a queued request's timeout begins
when its fetch starts.
Weird Castle was the first external consumer, with a full-screen Beacons demo in its own
engine module; its title sky never loads the engine.
The integration source is Black's `lane/game` at `126daba5` plus its working-tree
snapshot `321055e1` (2026-09-21). The original checkout remains intact.
## Start a game
Run these commands from the Exact2 repository root. With the pinned Bun and rustup
installed, `bun scripts/exact.mjs setup` installs the SDK's Rust toolchains,
wasm-bindgen, Binaryen and Bun dependencies, and builds the engine rustdoc that
`exact game vocab ` reads, so a first lookup skips that cold build (seconds, not
minutes). `setup --check` diagnoses missing or
mismatched tools. See [offline setup](#offline-setup) for populating Cargo's cache.
Cargo installs wasm-bindgen into `$CARGO_HOME/bin` (normally `~/.cargo/bin`);
include that directory in `PATH` for web builds and the dev loop.
Setup provisions source dependencies; it does not compile the optional engine.
```sh
bun game/new.mjs ./my-game
bun game/dev.mjs ./my-game
```
Open the dev server's printed URL. It answers with a building page before compiling.
`bun exact.mjs status` lists each web/prove invocation, its phase and elapsed time.
Start `web` in one terminal, then `bun exact.mjs web --ready --timeout 600` in another
to wait for that invocation's current build. It refuses a failed rebuild even when
the server still shows the previous game. A dead process is reported as `unknown`;
`running` means the process exists, not that the compiler is making progress.
The dev loop serves on 8765, or on the next free port when
another dev loop holds 8765 (it says so); `--port ` chooses one and fails at once,
before building, if that port is in use. `--lan` serves a phone on this network. Edit `my-game/logic/src/lib.rs` for gameplay or
`my-game/app.contract` for UI. `bun exact.mjs check` (or `bun game/app/shells.mjs --check`) checks the logic
library for type errors and determinism violations, without linking. The dev loop prints
that result before building each Rust save and keeps the running game when it fails.
Host, linker and target errors still belong to the build.
Gameplay reloads carry the running world in games without tables. In table games,
successful Rust saves rebuild the primary module and restart the world; table-only web
edits preserve it. Shared app-runtime edits reload the page. The server also prints **Open in native**.
Edits inside `Game::setup` take effect on a fresh game; in the starter, pause and
choose **Restart** to apply them.
The web dev loop builds the app host under the size-tuned `web` profile and its
GPU module under `gpu-dev`. After a Rust edit, it builds only the affected GPU module,
then runs wasm-bindgen and swaps it into the page without `wasm-opt`. The first
such edit compiles that profile's dependencies; later edits reuse them.
To explore the existing sample instead, run `bun game/dev.mjs beacons`.
`game/dev.mjs` passes the dev server's flags through (`--port`, `--lan`, and
`--allow-host `, repeatable, to serve the game through a tunnel such as
`tuft host`; without it a request under any name but the printed ones gets 421).
Once the game plays and its `proof.mjs` describes it, establish its proof baseline:
```sh
bun game/prove.mjs ./my-game
```
`prove` takes the first development baseline while you build your game;
`prove --release` checks the shipping matrix before you ship. A first baseline and
`--repin` run Linux and web under `gpu-dev` in continuous, Save and FreshGame modes.
`--release` adds native release and shipping-profile web runs in continuous mode.
With existing pins, ordinary `prove` runs `test-rust` first, then one native
`gpu-dev` continuous leg. It fails naming the tick if the simulation changed and
never writes `pins.json`. The first baseline publishes; subsequent publication,
including release coverage, requires `--repin` (`prove --repin --release` to ship).
Tests are excluded from pin inputs, so editing a test does not invalidate a pin.
Ordinary web builds and deploys keep the shipping profile.
Use `bun my-game/proof.mjs web --screenshot-only` for pixels only; this reports
`UNVERIFIED` because it skips gameplay checks. Its screenshot is
`my-game/artifacts/web/game.png`. The starter includes movement, jumping, two
beacons, pause/restart, touch controls, a ground grid, pads, fog, sun shadows and saved glow,
and the [gameplay kit](#what-every-game-needs) in its tick: a critter that follows the
player and keeps away from lit beacons, the coins it drops, and a two-item shop.
A bare name (`bun game/new.mjs my-game`) creates `game/games/my-game`; a path chooses
the directory. From an empty directory, `bun /path/to/exact2/game/new.mjs .` works too.
A game outside this checkout — `bun scripts/exact.mjs new ../my-game --game` makes
one, as `exact new` makes an app — also gets an app's `exact.mjs` and `AGENTS.md`:
`bun exact.mjs test-rust` (Rust tests), `web`, `web-build`, `test web`
(each `*.test.contract`; also `macos`, `ios`), `agent`, `mac`, `ios`, `windows`, `contract`,
`prove` and `feedback`, with the exact2 checkout named once (LLP 1086).
Generation resolves the generated workspace offline against the current SDK lock, so
the first locked test and proof need no lock refresh. Generation writes only inside that game. A game is the files its author writes; bakes
generate its hosts, Cargo workspace and lock, ignored, under `.shells/`.
| File | What you own |
|---|---|
| `logic/src/lib.rs` | Scene setup, typed arguments and the tick function |
| `app.contract` | Menus, HUD, layout, accessibility and app actions |
| `proof.mjs`, `logic/tests/*.rs` | Real-host assertions and hostless simulation tests |
| `app.test.contract` | Menus and HUD driven on a host (`bun scripts/agent.mjs --test`) |
| `pins.json` | Verified tick/save baselines, written by `prove.mjs` |
| `app.json` (optional) | Authored keys only: a title, bundle id, `game.audio`, `game.assets`, `game.look`, data and render crates |
| `logic/Cargo.toml`, `Cargo.lock` (optional) | Only when the game adds dependencies ([below](#exact2-integration)) |
The crate is `-logic` and the bundle id `com.exact.`; the title
follows the directory name. Run the Rust tests with
`bun game/app/shells.mjs ./my-game --test` — in a fresh clone too, with no bake and
no environment variables. It generates `.shells/`, resolves offline and locked, runs
the determinism lints and then `cargo test` on the game's crates. Tests in
`linux/tests/*.rs` are copied beside the generated Linux shell and run as Cargo
integration tests after building the game's GPU module. They can drive the
production presenter and the game's surface without a window or a GPU device.
The generated workspace carries the SDK's Rust toolchain pin, including for games
outside the SDK. Explicit `cargo +toolchain` and `RUSTUP_TOOLCHAIN` overrides still
apply. Run direct Cargo commands from the game's `.shells/` directory so they also load its deterministic compiler
flags and lint configuration; `--manifest-path` alone does not load that config
when Cargo starts elsewhere. That config also names an absolute `build-dir` (the
game's `target/`), where Cargo keeps its intermediate artifacts: a build kept apart
from it sets `CARGO_BUILD_BUILD_DIR` as well as `CARGO_TARGET_DIR`. Regenerate the shells after updating the SDK.
### Authored render hooks
Use `game.render` to add game-owned passes through the existing
`exact_game_render::Hooks` API. Keep GPU resources in an authored `render/`
crate; its Cargo package must match the declaration, end in `-render`, and set
`workspace = "../.shells"` under `[package]`. `bun game/new.mjs my-game --render`
scaffolds one:
```json
{"game":{"render":{"crate":"my-game-render","hooks":"Fog","shaders":"shaders::SHADERS"}},
"gpu":{"shaderRoots":["render/shaders"]}}
```
`hooks` and optional `shaders` are Rust paths exported by that crate: `hooks` its
`Hooks` type, `shaders` its reflected registry symbol (not shader files). Omit
`shaders` for the empty registry. Shader files and preludes use the explicit
`gpu.shaderRoots` and `gpu.shaderPreludes` declarations; nothing is discovered
implicitly. The registry has type `&'static [(&'static str, u64)]`, holding shader
names and reflected interface hashes. The generated GPU shell combines these hooks
with `game.audio` and `game.assets`. Never edit `.shells/` to install hooks. The
older `game.presentation`/`presentation/`/`type` spelling is refused with this
rename (LLP 1046.008 amendment); "presentation" now names only derived appearance
state (`#[derive(Presentation)]`, `Game::present`).
Render games also expose `exact-gpu.workspace = true` and
`exact-gpu-reflect.workspace = true` (the latter as a build dependency). The bake
writes the shader inventory, each shader after its `gpu.shaderPreludes` exactly as
it ships, to the directory `EXACT_GAME_SHADERS` names; `render/build.rs` calls
`exact_gpu_reflect::generate` on it, writes the generated Rust to `OUT_DIR`, and
exports its `SHADERS` table, so the registry matches what hosts register. A prelude
may name the renderer's own WGSL by set (`exact-game-render:frame`,
`exact-game-render:material`, `exact-game-render:material_shadows`, the files of
`FRAME_WGSL`, `MATERIAL_WGSL` and `MATERIAL_SHADOWS_WGSL`) rather than SDK paths.
Generated registry keys are file stems such as `fog`, without `.wgsl`.
The render crate may depend on logic to read component/resource types through
`RenderWorld`. Logic, data, native/web host adapters and build-time metadata must
not depend on it, including indirectly or behind target-specific/build
dependencies. The bake checks Cargo's unfiltered dependency graph. Hooks cannot
mutate the saved world through `RenderWorld`; gameplay state stays in logic.
The render crate receives normal strict Clippy and package tests, while the simulation
determinism lints remain scoped to logic. Run the ordinary bake after declaring the
crate, or `bun exact.mjs lock` when adding dependencies.
Source/manifest edits invalidate GPU builds and proof inputs; declared shaders keep
their existing reflection, delivery and live-reload behavior.
## The programming model
A game implements `setup` and `tick`. Components and resources hold all saved state;
a `Game` itself has no fields. This example is a runnable engine doc-test:
```rust
use exact_game::*;
struct Example;
impl Game for Example {
const ID: &'static str = "example";
type Args = ();
fn setup(w: &mut World, _: &()) {
w.prop("player", Mesh::cube(1.0)).at(0., 0., 0.).color("#fff");
}
fn tick(w: &mut World, _: &Input, _: &()) {
w.require_mut::("player").position.x += w.dt();
}
}
let mut sim = Sim::::new(()).unwrap();
sim.run(1000.0);
assert!(sim.local_position("player").unwrap().x > 0.0);
```
- A tick calls ordinary functions in the order you write them. Physics is an
explicit `physics::step(w)`; animation is `animation::step(w)`. Emitters step after the tick unless it
called `emitter::step(w)` to choose their order; they never step twice a tick.
- `#[derive(Component)]` declares per-entity data; `#[derive(Resource)]` declares
singleton data. Their `Data` representation supplies saves, hashes and agent JSON.
- Spawn literals with `w.prop(name, mesh).at(x, y, z).color("#4d6669")` or
`w.sprite(name, texture, [width, height]).at(x, y)`; `.entity()` returns the
entity for adding components. Tuples remain available. [Literal builders](recipes/literal-builders.md) shows both.
- Names and entity handles address the same world: `w.require_mut::("fox")`
names missing targets or components. `w.get::("fox")` returns an option.
- `query().iter()` yields `(Entity, item)` with borrowed references. Consuming
`query()` yields guarded items. `.one()` returns the sole item and refuses ambiguity.
Iteration always follows entity order.
- Borrows are per row. A query leases the rows it matches (after `.with`/`.without`);
`get`, `require` and the position helpers borrow one entity's row. Reading or writing
another entity's component inside a loop just works. One row takes many shared
borrows or one exclusive borrow; a conflict panics naming the component, the entity,
both borrows and both source lines. See [borrowing](engine/README.md#borrowing).
- The world owns fixed ticks, time and randomness. `w.dt()` is one fixed step;
`w.tick_end()` names the endpoint being authored. Rendering interpolates between
completed ticks and never changes the simulation.
Each enemy reads the player's Transform inside the loop that moves the enemies'
Transforms. The query holds only the enemies' rows, so the player's row stays free:
```rust
use exact_game::*;
#[derive(Default, Component)]
struct Enemy {
speed: f32,
}
let mut w = World::new(60, 7);
w.spawn_named("player", Transform::at(0.0, 0.0, 0.0));
w.spawn((Transform::at(10.0, 0.0, 0.0), Enemy { speed: 4.0 }));
for (_, (pose, enemy)) in w.query::<(&mut Transform, &Enemy)>().iter() {
let player = w.require::("player").position;
pose.position += (player - pose.position).normalize() * enemy.speed * w.dt();
}
assert!(w.query::<&Transform>().with::().one().unwrap().position.x < 10.0);
```
For a complete small game, start with [the template](new/logic/src/lib.rs) or
[Beacons](games/beacons/logic/src/lib.rs). The [engine reference](engine/README.md)
covers borrowing, registration, observation, saving and loading.
[Game recipes](recipes/README.md) answer one task each with a runnable `Sim` fragment.
`bun exact.mjs game vocab ` in a generated game shows rustdoc,
fields and evaluated defaults. [Familiar names](names.md) maps three.js, Unity and Godot.
### What every game needs
Steering, pickups, spawners and a shop are the engine's, as pure functions and
values the tick calls; nothing runs that the tick does not call, and nothing is
saved that the game does not save. The [starter](new/logic/src/lib.rs) uses each,
and its `logic/tests/sim.rs` has a bot that plays five minutes to check the prices
against the income.
| Need | Engine | Recipe |
|---|---|---|
| Seek, arrive, flee, wander | `steer::seek(&pose, target, max_speed, turn_rate, dt)` and its siblings return this tick's `Steer { velocity, facing }` | [Steering](recipes/steering.md) |
| A school | `steer::school(&pose, w.near_xz::(e, r).limit(7), separation, alignment, cohesion, …)` | [Schooling](recipes/schooling.md) |
| Pickups that come to the player | `collect::drift(item, player, radius, speed, dt)`, `collect::Collectable` | [Pickup](recipes/pickup.md) |
| Spawning on world time | a `spawn::Spawner` per kind in a resource; `spawner.due(w.tick())` | [Spawner](recipes/spawner.md) |
| A shop | `Actions` on `Digit1`…, rows with prices and lock reasons published to the HUD | [Shop](recipes/shop.md) |
A steer is a velocity and a facing, not a component: the game writes it into the
motion the entity already has. A creature with no physics moves its `Transform`
(`pose.position += s.velocity * dt; pose.rotation = s.facing`). A
[`Character`](engine/src/character.rs) is the mover when the creature should
accelerate, brake, jump and keep to bounds like the player: step it with
`s.velocity / character.speed` as the wish. A physics body applies the change of
velocity as its own force.
### Arguments, pause and restart
`#[derive(Args)]` supplies a typed argument struct. The canvas binds its field names:
`world(seed=7, paused=paused, restart=again)`. Omitted fields use Rust defaults;
`world()` uses every default. Positional calls follow declaration order; a call
uses either names or positions. The supported scalar types are bool, u32/u64,
i32/i64, f32/f64 and String; 64-bit integers must fit the portable safe-f64 range.
Ordinary fields reconstruct setup when changed. `#[live]` fields reach each tick;
`Game::paused` reads the pause field. A `#[restart]` boolean reconstructs on either
edge: the template toggles Contract's `again` state to restart. `Game::validate`
refuses invalid arguments before changing the world or clock.
Canvas arguments keep the app's current bindings when a world is restored. Use live
arguments for app-owned settings; keep choices that should travel with a game save
in components or resources.
`Game::render_scale(args)` sets the world's presented raster relative to canvas
device pixels: return a finite value in `(0, 1]`, which stands on every rung of the
quality ladder below. The default, `AUTO_RENDER_SCALE`, leaves the scale to the
rung (full size on `High`). The compositor stretches that raster to the same canvas
box. Camera layout and input coordinates stay in CSS pixels, and ordinary Contract
HUD children keep native resolution. Bind a `#[live]` argument through this method
for an app setting. This is the presented raster's size, and a look frame's; a
fixture's or a readback's size is the one its caller asks for.
`Game::render_scale` is read again at every frame, so a `#[live]` argument steps
it while the world runs.
`Game::render_idle(world, args)` lets a world that has come to rest stop
drawing. Return true when nothing it shows is changing (the dice lie still). The
canvas draws until both ticks it interpolates between are idle, then asks the
host for no frame until input, a bind, an asset, a resize or a render hook's
`Needs::ANIMATE` or `Needs::PENDING` wakes it; queued input and a pending hold or
tap keep it drawing. The idle time never runs as ticks: the first frame after a
wake advances one display period, and input that arrived meanwhile lands on its
first tick. The agent's clock ignores it, so a seeked run ticks every
millisecond as before. `Game::max_fps(world, args)` caps the drawn frames per
second (0, the default, for the display's rate), changeable at any frame: 60 at
rest, say, and 0 while the dice fly. The simulation still ticks at `HZ`; a render
hook's `Hooks::max_fps` can cap lower (a budget or a thermal cap), and the lower
non-zero cap stands. `Game::samples(args)` chooses scene sampling (by default
`Game::SAMPLES`); a change makes the renderer again under the same world, as a
"smooth edges" setting needs. None of the three is saved or hashed. The web, iOS,
macOS and Android hosts rest and cap a canvas; the Linux and Windows desktop
hosts draw a mounted world every display frame and take no cap
([GPU module ABI](../gpu/README.md#frame-pacing-and-timing)).
`Game::quality(args)` picks the renderer's rung of the quality ladder, read once,
when the renderer is made, and never saved: the world hash is the same on every
rung. The default `Quality::Auto` follows the device's GPU tier
(`exactViewport().gpuTier`, `high` under the agent until `prefer gpu-tier
low|mid|high`). The web chooses it before the GPU device from `GPUAdapterInfo`,
else the screen's class; a native module chooses it from the adapter it requests,
before it makes the device, and its host reports that as the page fact once the
module loads (`high` until then). A rung clamps only what the game left unsaid:
| rung | scene samples | sun shadows | raster scale | water's caustics and shafts |
|---|---|---|---|---|
| `Low` (a phone GPU of the Adreno 600 class) | 1 | none | 0.5 | not drawn |
| `Mid` (a recent phone or tablet) | `Game::SAMPLES` | 1 cascade | 0.75 | as `Water` says |
| `High` (a computer; the agent's) | `Game::SAMPLES` | as `Shadows` says | 1 | as `Water` says |
What the game says stands on every rung: an inserted `Shadows` resource (but for
its local lights' layer size, which `Low` caps at 1024² and `Mid` at 2048²), an
inserted `AmbientOcclusion`, and any `render_scale` the game returns (the default,
`AUTO_RENDER_SCALE`, leaves the scale to the rung). `Low` leaves out a `Water`'s
caustics and shafts at present time (`Renderer::set_water_effects`): the saved
resource keeps them. The rung is read once, from the
canvas's first arguments, when its renderer is made: a player's setting read in
`Game::quality` takes effect when the canvas is made again. A look frame is the
rung's picture too (its samples, clamps and scale).
On a Pixel 5 the starter draws its world at 16 fps under `High` and at the 90 Hz
display's 90 under `Low`; the world's `state` reports its row as `quality`
(`asked`, `tier`, `rung`, `samples`, `scale`). Under the agent `prefer gpu-tier`
remakes each world's renderer at the new rung, on the same device and under the
same world, on every host.
On a browser without WebGPU (or with no adapter) a game's web page shows, in the
box of each world surface (its build names them on the page's root,
`data-exact-worlds`, from the game's GPU declaration), a `role=status` page that names WebGPU and the browsers that have
it, and the game's iOS and macOS installs when `app.json`'s `install` lists any;
the canvas's own HUD is inert under it, the rest of the app keeps working, and a
world request answers `host web refused (gpu): no adapter: …`, which the proof's
web leg reports as its result. The page waits at most a second for the install
data, and goes, with the HUD's inertness, when its surface does. Any other GPU
surface, a game's or an app's (a decorative backdrop, such as Caltrain's sky), shows
no page: its content stays live over the empty canvas. No WebGL renderer
exists (LLP 1128 I6).
After the first GPU build, the compiler and dev loop check surface names and
arguments against the emitted `.shells/surfaces.json`, including hidden branches
and imports. Rust argument edits reach that declaration through the next GPU build,
so while the game's Rust is newer than it, `contract build` reports its findings as
warnings beside every other diagnostic, and `contract types` and `contract rust`
never stop on them; a bake checks them against the declaration it has just written.
The dev compiler retains its last good plan on an error. A native or web build also
checks the plan against the GPU product it packages, which carries its own argument
names: when a source changed while the build ran, so that a canvas passes a name the
product lacks or more values by position than it has arguments, the build stops with
"build again" rather than ship a canvas that fails to bind.
### Controls
`Game::actions` declares the controls once, by name; the tick reads them with
`input.held(name)`, `input.pressed(name)`, `input.released(name)` and
`input.stick(name)`. `pressed` is a rising edge since the last tick, even when the
release lands in the same tick; `held` is the state at the tick's end, so a quick
tap is `pressed` and never `held`.
A key is its `KeyboardEvent.code` (`KeyW`, `Space`); a mouse button is `MouseLeft`,
`MouseRight` or `MouseMiddle`; a pad button is its position in the W3C Gamepad API's
standard mapping, so an Xbox pad's A and a Nintendo pad's B are both `GamepadSouth`:
`GamepadSouth`, `GamepadEast`, `GamepadWest`, `GamepadNorth`, `GamepadL1`, `GamepadR1`,
`GamepadL2`, `GamepadR2`, `GamepadSelect`, `GamepadStart`, `GamepadL3`, `GamepadR3`,
`GamepadUp`, `GamepadDown`, `GamepadLeft`, `GamepadRight`, `GamepadHome` (`PAD_BUTTONS`;
another `Gamepad…` name panics at declaration). One action takes keys and pad buttons
together: it is held while any is down and released when the last lifts, so switching
device mid-hold neither presses again nor drops.
| Task | Declare and read |
|---|---|
| A fire button, one shot per press | `.button("fire", &["KeyF", "MouseLeft", "GamepadR2"])` read with `input.pressed("fire")` |
| Fire while held, and on a quick tap | `input.held("fire") \|\| input.pressed("fire")` (Rivals): `held` alone misses a tap whose press and release share a tick |
| Move with keys, a stick or touch | `Stick::wasd().or_arrows().or_pad(Pad::Left).or_touch(Region::Left)`; `.dead_zone(0.15)` (radial, the default) and `.curve(Curve::Power(1.5))` shape the pad's reading (Beacons) |
| Throttle and brake | `Stick::pad(Pad::Triggers)`: forward is `GamepadR2`'s travel less `GamepadL2`'s |
| Move by tilting the device | `Stick::wasd().or_tilt(Tilt { max_degrees: 20.0, dead_zone: 2.0 })`, fed by the app's `startSensorInput(surface="world", sensor="gravity")` (LLP 1133.013 D6): full deflection at `max_degrees` from level, nothing within `dead_zone` degrees, nothing before the first sample or after the input ends (`exact_game::tilt_stick` is the map). Level is the first sample, or what the tick sets with `w.set_tilt_neutral("move", gravity)` from a posted reading, from the next tick (Beacons' `tilt x y z`) |
| Read a sensor every frame | `input.sensor(SensorKind::Gyroscope)`, the latest sample, which holds through ticks that receive none and is gone after the input ends; `input.sensor_samples(kind)`, this tick's own samples, every one the host delivered |
| Sprint after holding a key | `.hold("sprint", &["ShiftLeft", "GamepadL1"], 0.2)`: `pressed` when held 200 ms, `held` until release; a shorter press fires nothing (Beacons) |
| A double tap | `.double("roll", &["Space"], 0.25)`: `pressed` on a second press within 250 ms |
| A short press distinct from a hold | `.tap("dash", &["ShiftLeft"])`: `pressed` and `released` together when a press of at most 200 ms (`Actions::TAP`; `tap_within` for another) lifts, so it fires late by design |
| A chord | `.chord("special", &["ShiftLeft", "KeyE"])`: `pressed` when the last key goes down while the others are held |
| Controls for one mode | `.context("driving", \|a\| a.button("brake", &["KeyB"]))` and `w.input_context("driving", true)`: its actions read only while it is active (Beacons' `light`, within reach of a beacon) |
| Let the player rebind | `w.rebind("jump", &["GamepadNorth"])`, or `w.rebind_text("jump GamepadNorth")` from a settings screen's `postMessage` |
| Keep the player's bindings for every game | `fn bindings(args: &Args) -> String` returning a `#[live]` argument the app binds from its storage: a new game copies it into `Bindings` |
Every window is world time: a trigger fires on the tick that contains its instant,
from the stamps of the events that moved its keys, so `clock +N` and a 60 Hz panel
fire the same ticks. A pending hold, tap or double window is work: `clock settle`
runs to its deadline and `state`'s `changing` names it (`input trigger sprint`). A context or a binding the tick changes applies from the next
tick: leaving the context, or rebinding a held action, releases it then (a journal line
names each), and a key already down as an action enters a context or gets it reads up
until it lifts and goes down again.
A host reports a pad's whole state when a button or an axis changed (in 1/256 steps),
stamped like a key; a press keeps its own stamp, and only a sticks-only change may fold
into a later one in the same tick. Pads go to the canvas that has the focus (on the Mac,
in the key window; on Linux, Windows and Android, with nothing focused, to the first
canvas that takes input; an editor's focus takes them from every canvas). When the focus
or the key window moves them, the canvas they leave hears each pad go, whether the focus
left the canvas or a control in it, and the canvas that takes them hears each connected
pad as the host last read it (the table says when that is). A blur releases everything
the world held, and a disconnect what that pad held; a pad that connects again at that
index is a fresh device. Of several pads, a stick reads the one pushed furthest (of two
pushed as far, the lower index); a button is down while any pad holds it. Per host:
| Host | Reads | Limits |
|---|---|---|
| Web | the Gamepad API, polled each frame while a pad is connected; a pad without the standard mapping is reported (`pad 1 connected unmapped` in the journal) and delivers nothing | as the browser exposes pads |
| macOS, iOS, tvOS | GameController's extended gamepad, on each change, and every pad as it is now when a canvas or a control in it takes or gives up the focus, when the app becomes active, and on the Mac when a window becomes or stops being key | a controller without the extended profile (the Siri Remote) is unmapped; on iPadOS with several windows the pads go to the earliest-made canvas holding the focus in any of them, not to the key window's |
| Android | gamepad keys and joystick axes; each trigger with an axis (LTRIGGER or BRAKE for the left, RTRIGGER or GAS for the right) reports its travel, one without its key; releases readings when the reader's window loses focus or the activity stops, keeping slots | every pad is reported standard; the host reads no pad's state on focus gain, so a pad reports itself with its next event, and a button held while unfocused reads up until pressed again |
| Linux | evdev, at each report, stamped with the report's time moved into the host's clock by one origin per read and kept when that report moves the pads to a canvas, so readings buffered in one read keep their spacing and several pads' arrive in the order they happened (ties by index, then as read); in the kernel's gamepad layout (Xbox drivers' letters read as their positions); a pad's state is read when it opens and delivered ahead of the reports buffered since | pads present at launch only; every pad is reported standard |
| Windows | XInput's four slots, every 8 ms while one is connected and every 250 ms otherwise, while the window has focus, and all four at once when it regains focus, so a pad unplugged meanwhile reads gone | `GamepadHome` is never down (XInput does not report the guide button); a reading is stamped at its poll |
`input.pointer()` is one primary pointer: the first contact down, or the hovering
mouse. For several fingers at once, `input.pointers()` is every contact down, sorted by
id: each touch, and the mouse while a button is held (`PointerContact`: `id`,
`position`, `press_origin`, `down`, `delta`). `input.pointer_events()` is this tick's
contact edges in the order they happened (`PointerEdge`: `id`, `phase`, `position`,
`world_us`): a `Down` where a contact began, an `Up` or `Cancel` where it ended. A move
is not an edge. A down and up in one tick are two edges, and the contact stays in
`pointers()` with `down: false` for that tick only. A blur cancels every contact. Input
applied while the game is paused leaves no edge, so a finger lifted during a pause never
reads as a tap; nor does an Up or a blur then, so a hold ends when its contact is no
longer down in `pointers()`, not at an edge. A save holds the contacts down with their
origins and no edges. Each finger holds the die under it:
```rust
use exact_game::*;
#[derive(Clone, Default, Data)]
struct Grab { finger: u64, die: Entity }
#[derive(Default, Resource)]
struct Grabs(Vec);
fn tick(w: &mut World, input: &Input, _: &()) {
for e in input.pointer_events() {
w.resource_mut::().0.retain(|g| g.finger != e.id);
if e.phase == PointerPhase::Down {
if let Some(die) = die_under(w, input.viewport(), e.position) {
w.resource_mut::().0.push(Grab { finger: e.id, die });
}
}
}
// A finger no longer down lets go, though it lifted during a pause.
let down = |id| input.pointers().iter().any(|p| p.id == id && p.down);
w.resource_mut::().0.retain(|g| down(g.finger));
let grabs = w.resource::().0.clone();
for p in input.pointers() {
if let Some(g) = grabs.iter().find(|g| g.finger == p.id) {
drag_to(w, g.die, input.viewport(), p.position);
}
}
}
// The game's own pick (a ray through the point) and drag.
fn die_under(w: &World, viewport: Vec2, at: Vec2) -> Option {
todo!()
}
fn drag_to(w: &mut World, die: Entity, viewport: Vec2, at: Vec2) {}
```
What is saved and what is hashed differ. A save holds the input queue and the keys,
pads and contacts held, which the world hash leaves out. The world hash covers the world
the input changed, plus resources that exist only once a game uses them:
`InputContexts`, `Bindings`, and `InputState`: the triggers' gestures in progress, the
latched actions, and the contexts and bindings as last applied. So two worlds whose holds
started at different times differ, as do two whose context or rebind has yet to apply,
and a restore mid-hold continues the hold. A sensor input's samples wait in the queue a save holds;
the latest sample of each sensor and the tilt neutrals are a fourth resource, `SensorState`, which
exists only once a sample arrives or a neutral is set. `InputEvent`'s `player` is 0 from every host and means
nothing yet.
### Movement and appearance
| Task | Existing engine operation |
|---|---|
| Move and jump without physics | `w.character("player").step(input.stick_xz("move"), input.pressed("jump"))` |
| Change speed while playing | `w.require_mut::("player").speed = 8.0` preserves velocity and the rest of the character. |
| Move against colliders | [Physics controller example](physics/tests/living.rs), with explicit `Move`, `Jump` and `Gravity` |
| Follow the player | Attach `Follow`; the scene steps it after the tick. Call `scene::follow(w)` explicitly to choose an earlier order. |
| Find a nearby unlit beacon | `w.nearest_xz_mut::("player", 1.5, \|b\| !b.lit)` returns its entity and mutable row handle together; `beacon.name()` reads its name. |
| Count for the HUD | `w.count::(\|b\| b.lit)` |
| Colour a surface | `Material::hex("#527b65")` (any CSS colour Contract takes: hex, `forestgreen`, `rgb(…)`); `Material::srgb(r, g, b)` for a picker's 0–1 floats. `Material::rgb` takes linear light, the renderer's own space, where 0.5 is not mid-grey; reach for it only for computed light |
| Sky, fog, tint and sprite colours | `Environment.background`, `Fog.color`, `Tint.color` and `Sprite.color` are a `Color`: `Some(Color::hex("#bad6d1"))`; `[r, g, b].into()` keeps linear values |
| Ring, torus, cone and halo | `Mesh::ring(inner, outer)`, `Mesh::torus(radius, tube)`, `Mesh::cone(radius, height)`, `Material::alpha(a)`; [halo recipe](recipes/halo.md). |
| Grid and fog | `Material::grid(Color::hex("#6f8486"), spacing)` and the saved `Environment`/`Fog` resource |
| An underwater scene | `w.insert_resource(Environment::underwater())`, a sun from above and no opaque surface over the floor; [underwater recipe](recipes/underwater.md) |
| A water surface, caustics and light shafts | `w.insert_resource(Water::at(8.))`: the surface at that height, seen from below through Snell's window, caustics on lit surfaces under it and shafts in the water (`caustics: None`, `shafts: None` to leave either out); [underwater recipe](recipes/underwater.md) |
| Fade a glowing mesh | Attach `Glow` to `Material::glow`; call `beacon.glow_to(1.0, 0.5)` once. The renderer samples at frame time. |
| Fade a point light | Attach `Lit(Spring)` and call `lit.to(w.tick_end(), intensity)`. |
| A flashlight | `SpotLight { inner, outer, range, intensity, .. }` along the entity's −Z; intensity in candela, as `PointLight`'s |
| A lamp without distance falloff | `SpotLight { decay: 0., .. }` or a `PointLight`'s: only the range, cone and angle shape it (three.js `decay`; default 2, inverse-square) |
| Shadows from a lamp | Add `LightShadows` to a `SpotLight` or `PointLight`; the renderer shadows the nearest few. `Shadows { local_resolution: 2048, local_filter, local_softness, .. }` sizes and filters every lamp's map |
| A moon | A second `DirectionalLight` (in entity order) is an unshadowed fill |
| Team colours, mutation looks | A `Material` on a model entity tints every node and adds emission; `NodeMaterials` per named node or `MaterialOverrides` per material (one model in every team's colours), written from `Game::present` ([team colour from a saved `Team`](render/tests/instances.rs): `p.insert(e, MaterialOverrides(vec![MaterialOverride { material: 0, color: Some(team_rgba), ..Default::default() }]))`) |
| A skin or theme picked while playing | One model per shape, its textures per instance: `MaterialOverride { material: 0, textures: TextureSlots { base_color: Some("walnut.tex".into()), ..Default::default() }, ..Default::default() }` from `Game::present`; each instance keeps its old look until the new textures arrive ([surface test](render/src/surface_tests/cosmetics.rs)) |
| A scrolling texture: a light running along a ribbon, flowing water | `MaterialOverride { material: 0, uv_offset: Some([head, 0.]), ..Default::default() }` from `Game::present` each frame; blended materials too, one draw, any period ([surface test](render/src/surface_tests/cosmetics.rs)) |
| No growth or compile when the first pieces spawn | `const PREPARE: &'static [(&'static str, u32)] = &[("die.model", 16)];` sizes the instance and skin buffers before the first frame and prepares the listed models' pipelines in the frames after; `state.world.perf.pipelineMs` times pipeline creation ([surface test](render/src/surface_tests/prepare.rs)) |
| A model generated when the player picks it | The tick writes `Mesh::asset("walnut-d6.model")`; `Game::present` calls `p.cosmetic_model(name, \|\| model)` for each name a `Mesh` shows. Never saved or hashed, freed when unshown; the entity draws its previous model until the new one can |
| Cheaper far trees and crowds | `ModelLod { levels: vec![LodLevel { distance: 30., model: "tree_low.model".into() }], hide: Some(120.) }` on the entity |
| Fade a tree between camera and player | `Opacity(0.3)` on the entity from `Game::present`: a dithered fade, no sorting, never in a save or pin |
| A first-person weapon | Add `ViewModel` to each part; it draws in front of the world and casts no shadow |
| Contact shadows in creases | `w.insert_resource(AmbientOcclusion::default())` turns on SSAO (off by default) |
| Lighting from a photographed sky | `w.insert_resource(EnvironmentMap::new("sky.tex"))` with the equirect in `Game::ASSETS` |
| Texture generated terrain | UVs in the `MeshData`, a material sampling a `.tex` from `art/textures/`, through `w.generated_model` |
| Fire, smoke and sparks | `ParticleLook { texture, atlas, fps, stretch, soft }` beside an `Emitter`; `soft: 0.5` fades smoke into the ground |
| A painted, visible sky | The same `EnvironmentMap` with `visible: true` (and `rotation` to turn it) |
| Mouse look | `input.pointer()`'s `delta` is the device's motion this tick, not a difference of positions. Mark the canvas `data-pointer-lock="true"` (declared in `app.json`'s `data`) and a mouse press captures the mouse on the web, macOS and iPadOS (`GCMouse`) until Escape or blur, so the delta never stops at an edge; the Linux host always sends evdev's relative motion. |
| Turn the drawn camera between ticks | Put `MouseLook { yaw_per_point, pitch_per_point, pitch_limit }` on the camera at the rates the tick turns it by: the drawn camera and its children turn by `Sim::unshown_motion()`, so a turn shows at the next frame whatever the tick and display rates (`engine/tests/look.rs`). Presentation only; insert it in `setup` or register it. |
| Frame the launch camera for the canvas's size | `fn first_frame(w: &mut World, input: &Input, args: &Self::Args)` on the `Game`: it runs once for each world `setup` made, with `input.viewport()` the canvas's size, before the first tick and before the first frame is drawn, so that frame already shows the framed pose. A restored world never runs it (its save holds what it wrote), nor does a world that ticked before it had a canvas. It writes the simulation as `setup` does, so the same size frames the same world (`engine/tests/first_frame.rs`) |
| Right or middle mouse button | Bind it as a key: `.button("aim", &["MouseRight"])`; `MouseLeft` and `MouseMiddle` too (`MOUSE_BUTTONS`). Touch contacts press none. |
| Start a selection rectangle | `input.pointer().and_then(\|p\| p.press_origin)` is the latest MouseLeft/touch Down point, even if movement and Up reach the same tick. Gate commands on your action's pressed/released edges. The origin survives normal Up and saves; Cancel, Blur or replacement clears it. |
| Several fingers at once | `input.pointers()` (every contact down, by id) and `input.pointer_events()` (this tick's Down, Up and Cancel edges, in order); [Controls](#controls) has a multi-finger grab |
`Character` saves velocity and configuration and reports displacement, grounded,
jumped and landed. `near`/`near_xz` use current global poses, inclusive radii and
entity-order ties. The starter uses Character's movement defaults and sets its ground
height and bounds explicitly.
`Glow` multiplies authored emissive output. Keep the material's authored colour
constant; copying the same animated intensity into it applies the effect twice.
See the [renderer reference](render/README.md#effects) for appearance conventions.
## Publications and events
Publish HUD values by name with ordinary Rust scalars:
```rust
use exact_game::*;
let w = World::new(120, 7);
w.publish("lit", 1);
assert_eq!(w.published("lit").unwrap().as_number(), Some(1.0));
```
Contract reads that record through the surface's name:
```contract
shape Hud
lit: number
component Screen
resource hud = exactSurface("world") as shape Hud
view
canvas surface=world() width="100%" height="100%"
text `Lit ${hud.lit}`
```
For a structured HUD, `w.publish_record(&hud)` publishes the fields of a `Data` record
together. Nested records, lists, options and scalars retain their names and JSON types.
Contract validates kinds against the shape: missing fields default, extra fields
are ignored. Enum variants are not Contract values. Rust field names are not
checked against the Contract shape at bake time. A rebuild or restore publishes
again; no app data module is needed. The whole record may be up to 16 MiB of JSON
(a whole inventory fits); a field change re-encodes only that field. A record over
the limit, or one the shape refuses, leaves the last accepted one standing and is
named, with its size and the limit, in the app's log and the agent's
`state.surfaceRefusals`.
Only the first live canvas owns a given surface's public record.
The record also carries `presented: bool`, which the host adds and the game cannot
publish (`publish("presented", …)` panics): true once a complete frame of the
current world has been handed to the compositor, on every host. It is false at
launch, and again after a restore, a restart or a lost GPU device, until the next
such frame; a loading cover over the canvas waits on it, since a value published
from `setup` means only that the declared assets loaded, not that a frame drew
(`if !hud.presented` in the view). It changes only at those moments, never per
frame.
`w.emit("won")` separately queues a string for the canvas's `message=` handler.
Undelivered events save in order but stay outside the simulation hash.
Commands go the other way as messages: an action calls
`postMessage("buy carrot", "world")` and the next tick reads every message posted
since, in order, from `input.messages()`. Nothing is coalesced, nothing needs an
acknowledgement, and a delivered message is not part of a later save. Use live
arguments for settings, messages for things that happen once. A paused world holds
a message for its first tick after the pause.
A message carries who posted it (LLP 1133.022 D6). One from a tool's commit, or
a wake's, arrives marked with that origin; the world keeps the least privileged
origin it has heard until a fresh start (and saves it), and marks each string it
`emit`s with it, so the `message=` handler runs as that origin and a tool cannot
start a device through the world. A person's or the app's own post leaves no mark;
`state` shows `origin` only for a marked world. The runner and the engine carry the
mark; a game never sees it.
The driver posts the same message with `post [] `, short for
`type message `: `post spawn-gulper` to the page's one world,
`post world "spawn gulper"` to a named surface (a canvas's testId works too). It is
the page's `postMessage` in every respect: saved until a tick reads it, held while
no canvas of the surface is live (64 at most, the 65th refused by name). Past the
world's 1,024 queued events the world drops it, logs that once and counts it in
`state`'s `input.refusedPosts`; the op has already succeeded. Use it to stage or script a scene the game
already listens for, as `Sim::post` does in a hostless test. As from the page, a
`set entity Component.field value` message is a
[development edit](engine/README.md#development-edits), which a pin refuses.
Typed simulation events use `#[derive(Default, Event)]`, `register_event`,
`emit_event` and next-tick `input.events::()`. Their bounded queues are resources
inside saves and hashes. Read delivered records with `state world events [Type]`.
String `emit` is the presentation path to Contract `message=`, outside the hash;
typed events do not deliver a second Contract message. Same-tick effects are function calls.
See the [tick admission contract](engine/README.md#tick-admission).
## Proof pins
The async lane runs `bun game/app/shells.mjs --test`. It gates each game's
hostless current-tick assertions: `Sim::assert_pin` compares only the tick the
simulation has reached, not every checkpoint or any continuation-save digest.
Later checkpoints not asserted by those tests and continuation saves remain the
real-host proof's responsibility. Advancing a hostless test to another pinned tick
adds coverage only when its options and inputs reproduce that proof's state.
`pins.json` owns the game's expected tick hashes and continuation-save digests.
`hosts`, `modes` and `profiles` record their coverage: modes `0`, `1` and
`fresh-game` run under `gpu-dev`; the `release` profile covers continuous mode
on the native host and web when requested. Missing `modes` or `profiles` means
the full matrix that older files used; `hosts` still bounds their host coverage.
A development baseline has its own publication verdict and reports `release not
pinned`. `proof.mjs` names missing host, mode and release coverage separately from
the simulation checks it ran. Deploy and push require a native host, web, all three
development modes and release. Native-only repins remain available but cannot ship.
An absent `profiles` field counts as release-covered under the legacy rule;
missing hosts never gain coverage.
The generated proof uses `pin(tick, snapshot)` and `pinSave(name, path)`;
Rust tests use `sim.assert_pin(include_str!("../../pins.json"))`.
A run containing a development `set` edit refuses both pin matching and repinning,
including after save/restore or journal rollover. `pinSave` inspects the saved bytes
and refuses admitted edits and complete edit commands retained in the input queue,
even after the live queue is cleared. Pending observations are tracked per session.
Production bakes refuse the command.
Pins cover the simulation only. Presentation state (`#[derive(Presentation)]`,
`Game::present`) is outside hashes and saves, so prove looks separately: assert
presentation values (`sim.world().get::(e)`) at an explicit tick, and compare
pixels at an explicit tick and alpha; never by moving a pin. "Settled" means the
simulation settled; a bobbing world rests. Agent state reports simulated poses
(`Transform`, `global`); the displayed pose is `World::drawn`, and presentation rows
appear beside the components they decorate, read-only.
```sh
bun game/prove.mjs ./my-game
bun game/prove.mjs ./my-game --hosts linux,web --compare-saves
bun game/prove.mjs ./my-game --hosts linux,ios --device --phone --compare-saves
bun my-game/proof.mjs linux --paranoid
bun game/prove.mjs ./my-game --repin --reason "describe the intended state change"
```
Repinning runs Off, Save and FreshGame and requires matching execution inputs and
host observations. With `--release` it also compares the fast profile with native
release and, when requested, shipping web. A profile disagreement, failed proof or missing requested browser
leaves the pins unchanged. Existing pins may be updated with
`--hosts linux` explicitly; the first baseline always requires Linux and web.
Summaries and every pin update record two digests: `inputs` identifies the exact
execution-input tree, including runtime files under `assets/` and `.baked-assets.json`,
even when Git ignores those generated products;
`sourceInputs` identifies portable authored sources. The latter follows authored-input tracking and omits the bake's
manifest-owned outputs and the manifest itself, and names app and SDK files
relative to their own roots. Rust crate `tests/` and `examples/` directories are
excluded only when their parent contains `Cargo.toml`, inside or outside the SDK.
Art and asset directories with those names remain inputs. Unowned assets remain authored,
whatever their names or extensions. Machine-specific texture encodings and sound PCM can therefore
share source provenance while retaining distinct execution identities. Repinning
still refuses mixed execution trees within one transaction, even when their
`sourceInputs` agree. A Git commit is recorded in `at` when one exists; a game
without commits records `at: inputs:` under the same agreement checks.
`CHROME` selects the headless browser.
Paranoid runs check simulation and saves. Save and FreshGame modes rebuild the
world through the normal restore path at the last tick of every advance (every
point a proof observes), at every tick that received input, and every 16th tick
inside an advance, which resets presentation interpolation. A save bug visible only
in a tick none of those cover (state that lives one unobserved, input-free tick
and is gone by the next sample) is no longer caught; run the proof with shorter
`clock` steps to sample more ticks. use ordinary runs for appearance and motion comparisons. The
driver moves a long `clock +N` in one-second steps, each its own operation, so a
seek's length is not bounded by a host's answer window (Chrome's 15 s).
`PASS` means the complete saved baseline was checked. Partial/build-only runs and
baseline collection report `UNVERIFIED`; failed assertions report `FAIL`. A
screenshot alone is not a verified simulation. Proof exits are **0 PASS**, **1 FAIL**
and **3 UNVERIFIED**, including build-only, pixel-only and baseline-collection legs.
Each verdict names its reason. `prove` accepts exit 3 from a collecting leg; it writes
pins only after the complete matrix agrees. Callers that treated UNVERIFIED as exit 1,
or build/collection runs as exit 0, must handle 3 explicitly. `diff` retains exit 2
for a refused capture. `prove.mjs` prints its unique
`ARTIFACTS` directory. Its `src/` copy supplies the game inputs and proof program;
edits to the original game do not change a running proof. `legs.json` records each
host/mode/profile result. Resume with `bun game/prove.mjs --continue `;
completed legs are reused and failed or interrupted legs rerun. Continuation refuses
changed game/SDK inputs, changed proof helpers, a changed matrix (including
`--compare-saves`) or damaged receipts. Keep the original comparison flags when resuming.
The SDK stays in its checkout and must remain unchanged throughout the run.
Proof imports and declared `export const proofData = ["relative/file.json"];` data
are checked separately from bake inputs; game-local dependencies are copied into
`src/`. SDK helpers remain live and are checked before and after every leg.
Snapshots share the original game’s `target/` for compilation and validated receipts
under `artifacts/build/`. A target lock covers proof build validation and copying;
each Linux proof then executes private products under its source copy's
`artifacts/build/`, checked against their input and product digests by the driver.
Linux GPU receipt hits restore the surface declaration and generated runtime assets.
The web leg can copy a completed resident producer build from the game's target
cache when its portable execution-input identity, mode and build configuration
match. The cache is populated from the invocation's private stage before publishing
the shared output. Reuse revalidates recorded compiler and build-script environment
dependencies, compiler identity and copied product digests before driving the snapshot. A changed
source, damaged product or different profile is a cache miss and builds normally;
the proof never reads the live world's state. Release builds do not use this cache.
Web GPU modules compile their paranoid mode into `Sim`; a continuous-mode
module cannot serve a Save or FreshGame proof. Those modes build separately
unless a completed receipt for that same mode matches. The seven-leg development
matrix reuses a warm continuous web leg, but that hit does not warm the Save or
FreshGame compilation. GPU-only hot-swap wasm is not reused; it needs a completed
page bake. The cache keeps six keys, evicting by receipt publication mtime.
Every proof leg checks each observed simulation's reported mode; Save and FreshGame
require completed reconstruction samples, including for each advanced world.
Game-local `.ts` proof helpers are relocated like `.js` and `.mjs`; an unsupported
`proofData` declaration is refused by name.
Direct `proof.mjs` runs use `artifacts//`.
Use `--report` on `prove.mjs` to explain recorded stalls and refusals.
`ios` selects the simulator; `--device` selects a paired phone and requires an
accessible development signing key. Direct proofs accept `ios --device --phone
` too. Phone artifacts and build receipts use `ios-device`; the proof's
1280 × 720 logical viewport fits into the phone window for equal input dimensions.
**The look result.** Beside the simulation result a proof reports a look result, on its own line:
`simulation: PASS · look: PASS, 0 tells`, or `look: FAIL, near-black`. At each
session's close the runner draws every world offscreen from its own camera at the
tick the clock holds (a look frame, [render README](render/README.md#look-frames)),
the page as composited where the canvas draws, and a contact sheet over 600 ms at
half resolution, and prints each as a `LOOK` path. A tell fails the look: a
near-black or near-white frame, more than 5% of pixels clipped, a particle draw that
covered nothing (no fragment of it contributed in front of the scene), a shadowed
sun whose maps hold no texel nearer than their clear depth. Tells are judged
on whole frames from the game's camera: the close step's, the one a drawing host
takes at every `clock settle` and pixel `screenshot` (their replies carry its
`look` lines, `lookGaps`, `lookFailed` and the canvases looked at, `lookSeen`), and
each frame of a full-resolution `from camera` film. The contact sheet and the orbit
and plan views are pictures. A tell is counted once a frame that told it, and fails
the look alone: the simulation result and its failures do not change, and a repin
records its pins. A look the game intends is silenced by name in `app.json`,
`"game": {"look": {"silence": ["near-black"]}}`, and still printed and recorded.
The look passes only when every world any reply showed (a settle's or a state's
world list, a tree's canvases) was looked at, at its close, in a judged frame or by
a `look(session)` from the game's camera, and every measurement a tell reads was
taken. A world with no picture (the GPU-less host
without a GPU, or a model-capable module there: `look: no picture on this host`), a
drawing canvas with no picture, a look frame that failed, a world shown and never
looked at, or a gap (`particles: not measured`) leaves it `UNVERIFIED`, named on a
`look: unverified` line. `prove.mjs` passes the look only when every requested
host's every run did; a run with no summary is a gap. The tells see brightness and
coverage, not colour, facing or layout: open a `LOOK` picture once before reporting.
A proof script takes its own with `look(session, view, label)`, `view` being `{}`
(the game's camera, judged), `{top: 30}` or `{yaw: 35, pitch: -20, distance: 40}`;
the look step runs in mode 0 only, beside the simulation the paranoid modes check,
whose journal comparison leaves the `look:` lines out.
The [shared proof runner](proof.mjs) handles builds, sessions, transcripts and
cleanup. Each game's proof owns its assertions. `check` records failures while
independent assertions continue. The terminal shows checks and failure details;
`replies.json` retains complete operation replies. Browser runs provide canvas pixels; the GPU-less
Linux host exercises simulation, input, Contract UI, CPU picks and saves.
Proof receipts bind the completed build's inputs. Editing authored inputs during
a build rejects the proof and removes its receipt. Directory tracking includes
the app root, shared sources and every admitted source subtree, including empty
directories, so creating and removing a source
during compilation also rejects the proof. Changes to entries in a tracked
directory require a rerun, even when the changed entry is excluded from inputs.
Asset directory entry changes also require a rerun, including generated file
creation, pruning and atomic replacement. The baker uses atomic replacement for
changed assets. Rerun after the tree settles; the proof rebuilds before caching
the result. In-place rewrites of manifest-owned generated files can complete in
the same invocation. The app root is tracked whether or not `assets/` exists,
so temporary files and source trees there cannot go unnoticed.
Direct web proofs build into `artifacts/web-dist/`. The web builder replaces
that directory on every build, so an `EXACT_WEB_DIST` override must also live
inside an excluded output tree, such as `artifacts/` or `target/`, or outside
the source trees. Otherwise each build changes a monitored parent directory
and the proof cannot accept its receipt.
### Compare captured world state
In a proof or an agent session, capture inspected state before and after an action:
```js
import {captureWorld, diffWorlds, formatWorldDiff} from '/path/to/exact2/game/proof.mjs';
import {writeFileSync} from 'node:fs';
const before = await captureWorld(session, 'world');
await session.world('world').hold('KeyW', 500);
const after = await captureWorld(session, 'world');
writeFileSync('before.json', JSON.stringify(before));
writeFileSync('after.json', JSON.stringify(after));
console.log(formatWorldDiff(diffWorlds(before, after)));
```
Compare those files later with `bun game/proof.mjs diff before.json after.json`.
Exit codes: 0 for equal inspected values and hashes, 1 for differences, 2 for a
refused capture. The report shows field paths and before/after values, including
added/removed entities, components, resources and array entries. It includes
arguments, input and published values; tick/hash are report metadata. Named
entities match by name even if their slots change; unnamed entities match by
slot ID (inspection does not expose generations, so slot reuse is indistinguishable).
Arrays compare by index. The default report retains 100 differences and counts
omitted ones; `diffWorlds(a, b, {limit: 500})` changes that output limit.
These JSON captures are **not `.world` binary saves** or a complete explanation of
a hash. Opaque executor state stays opaque; inspection can omit or round internal
values. A differing hash with equal inspected values is reported explicitly.
Capture on the agent clock without concurrent drives. Mixed-tick/hash reads and
incomplete entity lists are refused; `captureWorld` reads every page. Capturing and
comparing state does not advance the clock or mutate the world.
## The agent's interface
Use Exact2's same ten operations: `tree`, `screenshot`, `tap`, `type`, `state`,
`layout`, `logs`, `clock`, `prefer` and `perf`. `trace ` reads an exported
trace separately from these session operations. The CLI also accepts `check`,
`sample`, `resize`, `close`, `back`, `pick`, `reload`, `fail fetch` and `pass fetch`,
and `post`, a form of `type` (above). An entity is addressed as `world:player`.
The agent drives a pad as it drives keys: `type world key GamepadSouth [for ]`
presses a pad button and `tap world stick left 0.7 -0.2 [for ]` moves a stick, in
the Gamepad API's axes (positive y is down). Each is the agent's pad 0, held across
requests and queued as a host queues a real pad (`delivery: synthesized`); without
`for`, `tap world stick left 0 0` lets the stick go. `state world input` lists the
declared actions and their keys, the keys and pads held, contexts, bindings, the
actions held, and the actions pressed and released at the last tick.
```sh
EXACT_APP_DIR=./my-game EXACT_WEB_DIST=./my-game/artifacts/web-dist bun scripts/agent.mjs web \
"tap play" "type world key KeyW for 1500" "layout world:player" \
"state world:* busy" logs "screenshot run.world world save"
```
For a `prove.mjs` web build, set `EXACT_WEB_DIST` to the printed artifact directory's
`dist/`. `state world:*` reads entities a page at a time (512 by default):
`state world:* from 512 limit 2000` reads the next ones, and the reply carries
`total` and `next`; `under world:player` narrows it. `state world:* resources` adds
every resource's value. `logs` reads only new journal lines; `logs --all` reads
all retained lines (the journal holds 4,096), reporting any dropped prefix.
`perf world:world` reads exclusive tick spans with source lines and p50/p95
milliseconds; `perf world:world during "clock +1000"` measures that drive.
In a proof, `session.world('world').snapshot({all:true})`
reads every page at one tick and `resources()` the resources.
`clock settle` advances the owned clock and explains remaining work instead of sleeping.
A world answers a few questions about its picture from a look frame, the tick the
clock has reached drawn offscreen (presentation only; no tick runs and the hash does
not move):
```sh
bun scripts/agent.mjs web "tap play" "clock settle" \
"screenshot side.png world from 35 -20 40" "screenshot plan.png world from top 60" \
"state world draws" "state world:beacon-1 visible" "state world look"
```
`screenshot