Docs Design

Contents

M1 design: the player and the Tideline slice

Status: accepted for building, 2026-09-30. Read with ARCHITECTURE §3–§8, ADRs 0002–0007 and 0010, and research/unspent-ortho-study.md §4 (the slice and its acceptance).

M1 makes one generic Godot player that runs any bundle, a first set of kits for the action_survival template, and Tideline, a small unspent-style game written by hand as a Game Pack. No AI. This document fixes the contracts, so several builders can work in parallel without guessing: the bundle, the kit interface, the simulation API, expressions, input, events, saves, tours, traces and the engine CLI.

1. Contracts first (landed before fan-out)

Contract File Owner
Bundle manifest schemas/v1/bundle.schema.json core
Kit registration schemas/v1/kit.schema.json core
Kit vocabulary (generated) schemas/v1/vocab.schema.json codegen from engine/addons/gamewright/kits/*/kit.json
Simulation API engine/addons/gamewright/runtime/sim/*.gd stubs with doc comments engine
Expr bytecode §4 of this document, and testdata/expr-vectors.json core + engine
Tour language §8 of this document engine
Trace and result formats schemas/v1/trace.schema.json, schemas/v1/engine-result.schema.json core

A builder that needs a contract changed proposes the change in its report. It doesn't edit another track's contract silently.

2. The bundle

gamewright bake <pack> writes <pack>/.gamewright/build/<hash12>/, where hash12 comes from the whole manifest (so a change to kit versions or media also makes a new folder):

bundle.json    manifest: game id, engine range, schema version, template pin, kits, content hash,
               media list, entry, the world stamp inputs
content.json   one canonical JSON array of every resolved document (template + packs + game),
               sorted by id
exprs.json     compiled expressions: [{src, type, code}], one per distinct source text
media/<sha256>.<ext>
  • Resolved means: template and pack documents merged with the game's, overrides by id applied, id-addressed edits applied, defaults filled from the schemas. The engine never resolves anything.
  • Expression fields keep their source text in content.json. The engine looks the compiled program up in exprs.json by source text. Compilation is context-free; typechecking, which depends on context, already happened in the core.
  • The world stamp is SHA-256 over the RFC 8785 bytes of {sim, kits, worldgen, biomes, rootSeed}: the simulation version, the kit versions, the generator documents and the seed.
  • The player verifies sha256(content.json) and sha256(exprs.json) against the manifest, every media file against its name, and the engine range against its own version. A failure stops with a named reason.
  • content.json is one fetch on the web. Media are fetched lazily by hash.

3. Kits

A kit is a folder engine/addons/gamewright/kits/<name>/ with a kit.json and typed GDScript.

{
  "kind": "Kit", "schemaVersion": "1.0", "id": "kit.melee", "name": "Melee", "version": "1.0.0",
  "description": "Blows with windup, active and recovery windows; dodges with invulnerable frames; plate that rings and a working part that takes damage. Use for close combat where tactics beat trading hits.",
  "requires": ["kit.core", "kit.move"],
  "systems": [
    {"id": "melee", "script": "melee_system.gd", "after": ["move"], "provides": ["hits"], "consumes": ["intent"]}
  ],
  "views": [{"id": "melee_fx", "script": "melee_view.gd"}],
  "components": [
    {"id": "melee", "description": "…", "params": { "type": "object", "additionalProperties": false, "properties": { … } }}
  ],
  "actions": [{"id": "damage", "description": "…", "params": { … }}],
  "events": [{"id": "melee.hit", "description": "…", "fields": [{"name": "attacker", "type": "entity"}, …]}],
  "functions": [{"id": "in_windup", "description": "…", "args": [{"name": "e", "type": "entity"}], "returns": "bool"}],
  "passes": [], "tasks": [], "tourVerbs": [], "saveKeys": ["melee"]
}
  • The core reads every kit.json and generates schemas/v1/vocab.schema.json: a discriminated union of components ("component": "<id>"), of actions ("action": "<id>"), of passes ("pass": "<id>"), plus enums of event, function and task ids. Document schemas reference these unions. The Expr typechecker reads function signatures from the same files.
  • Parameter schemas follow ADR-0005 (closed, no recursion, units in names, Expr where a number is tunable: {"anyOf": [{"type": "number"}, {"$ref": "common.schema.json#/$defs/Expr"}]}).
  • kit.core is the always-on kit: entities, stats, tags, variables, rules, the Expr VM, the event bus, timers, and the basic actions (spawn, despawn, set_var, add_var, set_stat, add_stat, emit, give, take, play_cue, show_message, end_game). Vocabulary ids are lowercase with dots and underscores (kit.schema.json DottedId), so action ids are snake_case.

4. Expressions (L1)

Syntax: a Python/GDScript expression subset. Literals (numbers, true, false, strings in single quotes); + - * / %; < <= > >= == !=; and or not; a if cond else b; parentheses; member access on roots; calls to registered functions. No assignment, loops, lambdas or indexing.

Roots: self, target, source, other (entities, where the context provides them), event (the triggering event's fields), game (tick, hour, minute, day, seed), var (game variables), tune (tuning knobs), stat (not a root; stats are read as self.health). Entity members: stat names, alive, x, y, z, level, and component state fields as self.<component>.<field>.

Types: number, bool, string (ids and tags), entity, vec3. The core typechecks each field against the type its schema declares, with the roots the document's context allows.

Bytecode: an array of instructions, each an array. The engine VM (GwExprVm) runs it with a step budget of 256 and returns the value or an error.

Instruction Meaning
["n", 3.5] ["b", true] ["s", "tag"] push a constant
["get", "self", "health"] ["get", "self", "perception", "sus"] push a root member
["call", "clamp", 3] call a registered function with the top 3 values
["add"] ["sub"] ["mul"] ["div"] ["mod"] ["neg"] arithmetic (division by zero yields 0 and a warning)
["lt"] ["le"] ["gt"] ["ge"] ["eq"] ["ne"] ["not"] comparison and logic
["jf", 4] ["jt", 4] ["jmp", 4] relative jumps (for and, or, if else)

testdata/expr-vectors.json holds source → bytecode → value cases, run by both the core (compiler) and the engine (VM) tests.

5. The simulation API

All in engine/addons/gamewright/runtime/sim/. Pure RefCounted classes; no node access.

class_name GwSim            # one per running game
var content: GwContent      # documents indexed by id and by kind
var tick: int               # ticks since start
var hz: int                 # ticks per second (template sim.tickHz, default 60)
var root_seed: int
var entities: GwEntities    # spawn(arch_id, at, stable_id) / despawn / get / by_tag / all
var vars: Dictionary        # game variables (JSON values)
var events: GwEvents        # emit(id, fields) / on(id, callable) / this_tick()
var vocab: GwVocab          # actions, functions, passes, tasks, tour verbs by id
func rng(path: String) -> GwPcg32        # a named stream: deriveSeed(root_seed, path)
func expr(src_or_value: Variant) -> GwValue  # a constant or a compiled Expr
func step(frame: GwInputFrame) -> void   # one tick: systems in order, then rules
func state() -> Dictionary               # every system's save_state() plus core state
func state_hash() -> String              # sha256 of JSON.stringify(state(), "", true, true)
func system(id: StringName) -> GwSystem  # only for ids this system declared in `consumes`

class_name GwSystem         # base class for every kit system
var sim: GwSim
func setup() -> void        # read documents, register vocabulary, build initial state
func start() -> void        # after every system's setup
func step(frame: GwInputFrame) -> void
func save_state() -> Dictionary
func load_state(d: Dictionary) -> void

class_name GwEntity         # plain data
var id: StringName          # stable: "player", "sentry.0", "region.r0/prop.pine.17"
var arch: StringName
var tags: PackedStringArray
var stats: Dictionary       # name -> float
var comp: Dictionary        # component id -> Dictionary of that component's state
var pos: Vector3            # world units; +Y up
var facing: float           # radians, 0 = +X
var alive: bool
  • Order. GwKitRegistry reads the kits the template and packs name, sorts systems by after/provides/consumes (a cycle is an error naming the systems), and calls setup then start. Rules run after all systems each tick.
  • Props are not entities. Thousands of world props live in the world kit's packed tables (unspent's design). They get stable ids from their stream and index, so saves and tours can name them.
  • No floats in decisions where a generator decides; quantize first (ADR-0004).

6. Input

GwInputFrame: move: Vector2 (world-space intent, length ≤ 1), aim: Vector2, down: PackedStringArray (held actions), pressed: PackedStringArray (pressed this tick). Actions are the ids in the game's InputMap document. The pointer (2026-10-09, pointer-input.md) adds point (where it is on the ground, world x and z on a 1/64 m grid), clicked and, on a click, target (what the view picked under it: an entity id or prop: and a prop's id): world terms only, never pixels, so a replay needs no camera.

Sources: GwDeviceInput (Godot InputMap built from the document at boot, camera-relative movement turned into world intent), GwTourInput, GwReplayInput, bots. The player polls exactly one source per tick. The devices latch each button press from its event until the next tick, so a tap that goes down and up between two ticks (one browser frame) still presses once. While a view holds the keys (a slate app), the game gets an empty frame, and the first frame after the view lets them go presses nothing: the key that closed the app went down for the app. A replay file is a header (bundle hash, seed, engine version) plus one frame per tick (run-length encoded) and a state hash every 60 ticks. It covers the run since the last boot, load or staging (--at, --hour, a tour's hour: changes no input frame repeats), and keeps a start save whenever the game it starts from is not a fresh boot.

7. Presentation

GwView extends Node (2D or 3D subclasses): bind(sim: GwSim, stage: GwStage) once, then present(alpha: float) every rendered frame, reading simulation state and interpolating. Views never write to the simulation. GwStage holds the world root node, the camera rig, the light pool, the render profile and the builder cache. What a view carries from one tick to the next it takes in in ticked(), called after every step; what it spreads over frames (a camera's easing, chunks streamed in) it finishes in settle(), called before a shot. A tool run draws no frame between its shots (fast-runs.md), so a shot must not depend on frames drawn.

Builders (runtime/present/builders/): pure functions from a recipe document (plus seed and palette) to a Mesh, Image, or AudioStreamWAV, cached by the recipe's content hash. Each builder has a version; golden-hash tests pin its output.

8. Tours

Line-oriented, one command per line, # comments. Everything is staged by name.

seed 3
boot title
press confirm                   # start a new game from the title
await control 10                # fails if the player has no control within 10 s of game time
goto prop:driftwood             # path to the nearest driftwood prop (world kit's nav)
use until taken:driftwood 5
repeat 2 { goto prop:driftwood; use until taken:driftwood 5 }
make rcp.campfire
await made:rcp.campfire 3
hour 19.5                       # set the clock (only between shots; logged)
toggle lamp
shot night-lamp with player,light:lamp
goto near arch.sentry 12
await noticed:player 20
await tell:sentry.0 5
press dodge
...
save slot1
quit title
load slot1
assert same-state
  • The pointer is staged by name too: point at WHAT (an entity id, prop:<kind>, place:<name>; followed as it moves) and point off, aim DEG (round the player, as facings go), click [WHAT] (once, then the pointer stays there); a click game walks there, await arrived 20 (pointer-input.md §4).
  • Verbs come from kit.core (seed, boot, press, hold, release, point, aim, click, wait, await, until, assert, shot, screen, save, load, quit, hour, repeat) and from kits' tourVerbs (goto, use, make, toggle).
  • screen ID holds the game's Screen ID open over the game for the shots after it, as a person reaches it (an app on the slate, the title); screen alone goes back to what the game shows. It gives no input and steps nothing (2026-10-02, m3-verification.md decision 13). The player's --screen ID is the same from the start.
  • await WHAT SECS fails on timeout; until WHAT SECS never fails. Waits are in game seconds (ticks), never wall time. On the desktop a tour steps as fast as the machine allows, drawing only at its shots (fast-runs.md); in a browser it keeps the game's rate.
  • shot NAME with SUBJECTS waits for the subjects to be in frame and unoccluded, captures at that tick, and fails if they aren't there at the shutter.
  • A tour passes only if it prints tour <name> done and the log has no script, shader or parse error.

9. Traces and results

  • Trace (trace.jsonl): one JSON object per line, each with a type. header (bundle hash, seed, engine version, input source) comes first; boot marks a boot or a load, and staged a change outside the input (a tour's hour), where a recording restarts; tick lines appear for ticks that had events, with t, the input frame, the player's state and the events; hash lines carry the state hash every 60 ticks; footer ends it with the outcome and counters.
  • Engine result (result.json, and the last stdout line): {ok, command, bundle, seed, ticks, stateHash, worldDigest, outcome, diagnostics[], files[]}.

10. The engine CLI

godot --path engine res://cli/cli.tscn -- <command> [options], wrapped by tools/engine-cli.sh.

Command What
version engine info
digest --bundle B --seed S generate the world and print its digest (no rendering)
sim --bundle B --seed S --ticks N [--tour T] [--bot ID] [--replay R] [--trace F] headless simulation as fast as the CPU allows (a plain loop over step, not the scene tree's clock)
shot --bundle B --seed S [--at NAME] [--hour H] --out F.png [--size WxH] one frame through the frame server; needs a renderer
(tour) tours with shots run in the player app (tools/play.sh --tour T --shots DIR, or gamewright tour); headless tours without shots run through sim --tour
compare image compare (from Reelwright)

The player app itself (res://player/player.tscn) takes the same boot options for people: --bundle, --seed, --level, --at, --hour, --tour.

11. Tideline

examples/tideline/ pins tpl.action_survival@1 from library/templates/action_survival/. Scope, acceptance and exclusions: research/unspent-ortho-study.md §4, restated as ROADMAP M1.

Content Documents
World gen.tideline (192x192 tiles: height, terraces, a cliff band, a tarn, the sea, beach, heath), biome.coast
Player arch.wanderer (move, perception target, melee with a knife swing, inventory, lamp)
Machine arch.sentry (move, perception with a sight cone and hearing, sm.sentry patrol → notice → hunt → attack → forget, melee bite with a tell, plate front, working part on the back, loot loot.sentry_scrap)
Things items item.driftwood, item.stone, item.scrap, item.knife, item.lamp; recipe rcp.campfire; station archetype arch.campfire
Look style.tideline (palette, MADE and FOUND material families, night level, fog, light budget), models model.pine, model.driftwood, model.boulder, model.wreck, model.sentry, model.wanderer, model.campfire
Sound surf and wind bed (day and night), footsteps per ground, the sentry's hum, call and windup tell, ring and hit, gather, UI click
UI ui.title, ui.hud (health cells, clock, held item, message line, use hint), ui.carry, ui.make on one slate frame
Proof playtests/proof.tour, playtests/tactics.playtest.json, playtests/stealth.playtest.json

12. Tracks

Builders work in worktrees on branches, and merge in this order. Each track ends green on tools/check-all.sh with its own tests.

Track Scope Depends on
T1 Runtime bundle loader and checks, GwContent, GwSim, entities, events, input frames, kit registry and ordering, the Expr VM (against the vectors), the rules and state-machine runners, kit.core, saves and the world stamp, the trace writer, CLI digest/sim, the tour runner (core verbs), the player app shell, the frame server and CLI shot contracts
T2 Core M1 schemas, vocab codegen from kits, semantic validation, obtainability, the Expr compiler (against the vectors), bake, and the CLI wrappers play, sim, shot, tour, verify (L0–L2) that run engine jobs contracts
T3 World and look kit.world_tiles (grid, levels, grounds, step and cliff rules, water, nav), worldgen passes, the terrace mesher and chunks, the lit_ortho3d profile in Compatibility (colour door, MADE/FOUND materials, day/night sky, fog, light pool, shader warm-up), the ortho camera rig with texel snap and stipple occlusion, the mesh kit and archetype builders T1 interfaces
T4 Play kits kit.move, kit.perception, kit.brain_fsm (uses the core runner), kit.melee, kit.harvest, kit.inventory, kit.crafting, kit.clock, kit.light_carried, with headless tests, including the tactics and stealth checks T1 interfaces, T3 world queries
T5 UI and sound the Screen-driven UI framework and the slate look, the synth (SFX recipes, beds, loop-aware), the sound bank baked to WAV with a mix sheet, and cue mapping T1 interfaces
T6 Tideline the template library, the Tideline pack, the proof tour, playtests, the web export of the player with the bundle, and acceptance A1–A10 all

13. Document shapes for M1

As built (T2): only template is required in a GameManifest, and bake fills theme, style, world, input and camera with the only document of each kind; a template pin may be "1", "1.2" or "1.2.0" and the bundle records the exact version; Model and Sound params are checked against the builder's generated parameter schema by the core (JSON Schema can't select a shape from a sibling field); GameBrief stays out of bundles. The table below is the original contract.

Envelope on every pack document: kind, schemaVersion, id, name, meta. Fields ending in Expr accept a number or bool, or an expression string. <Union> means a discriminated union generated from kit registrations. T2 writes the JSON Schemas to match this table; a track that needs a change proposes it.

Kind (id prefix) Fields
GameManifest (game.) template {id, version}, packs[], theme, style, rootSeed, sim {tickHz}, world (WorldGen id), player {arch, spawn} (spawn is a place name), flow {title, hud} (Screen ids), input (InputMap id), camera (CameraRig id), kits[] (resolved at bake from template and packs), platforms[]
Stats (stats.) stats[] {id, description, min, max, default}, vars[] {id, default}
Archetype (arch.) tags[], stats[] {stat, base: numberExpr, max?: numberExpr}, components[] <Component> ({"component": "<id>", …fields}), model? (Model id), cues[] {on, cue} (event id → Sound id)
Item (item.) description, bulk, stack, tags[], tool? {verb, hardness, edge}, model?, icon?
Recipe (rcp.) station? (arch id; none = by hand), inputs[] {item, count}, outputs[] {item, count}, builds? (arch id placed in front of the maker), timeMin, tool? {verb, hardness}
LootTable (loot.) entries[] {item, count, weight: numberExpr}, rolls
RuleSet (rules.) rules[] {id, on, where?: boolExpr, do[] <Action>, once?, cooldownSec?}
StateMachine (sm.) initial, states[] {id, parent?, onEnter[] <Action>, onExit[] <Action>}, transitions[] {from, to, on?, when?: boolExpr, afterSec?: numberExpr}
WorldGen (gen.) sizeTiles [w, h], chunkTiles, biome (Biome id), passes[] <Pass> ({"pass": "<id>", "id": "<slug>", "after": […], …fields})
Biome (biome.) grounds[] {id, walk: {speedMul, noise}, look: {material, palette}}, props[] {id, model, stages, blocksBody, cover, heightM, take? {verb, hardness, minutes, yields[] {item, count}}}, water {level, palette}
Model (model.) builder (a builder id), params {…} (the builder's fields), palette[] {slot, color}, budget {tris, draws}
Sound (snd.) builder (synth.sfx, synth.layered, synth.bed), params {…}, category, levelDb, variants, loop
Screen (ui.) frame? (a slate frame style), widgets[] {id, parent?, widget: {type: frame | label | bar | cells | list | icon | hint | button, …}, anchor, bind?: Expr}
InputMap (input.) actions[] {id, description, keys[], mouse[], pad[]}
CameraRig (cam.) rig: ortho3d, yawDeg, pitchDeg, viewHeightM, distanceM, followRate, snapTexels, occlusion: stipple | none
Style (style.) profile: lit_ortho3d, palette[] {slot, color}, materials[] {id, family: made | found | water | foliage | emissive, …}, sky {sunColor, moonColor, ambientDay, ambientNight, fogColor, fogDensity}, lights {budget, warmColor, coldColor}, ui {tokens}
Tuning (tune.) knobs[] {id, value, min, max, why}
Playtest (test.) seed, tour? (a path in the pack), bot?, ticks, assert[] boolExpr, metrics[] {id, min?, max?}
Template (tpl., in library/) version, kits[], defaults[] (document ids the template supplies), knobs[], questions[], acceptance[]

Test fixtures: until T2's bake exists, each track builds GwContent in its tests from dictionaries of these shapes, kept under engine/tests/fixtures/<track>/.

From docs/design/m1-player.md in the Gamewright repository.