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 inexprs.jsonby 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)andsha256(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.jsonis 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.jsonand generatesschemas/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.coreis 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.jsonDottedId), 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.
GwKitRegistryreads the kits the template and packs name, sorts systems byafter/provides/consumes(a cycle is an error naming the systems), and callssetupthenstart. 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) andpoint 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 IDholds 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);screenalone 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 IDis the same from the start.await WHAT SECSfails on timeout;until WHAT SECSnever 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 SUBJECTSwaits 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> doneand the log has no script, shader or parse error.
9. Traces and results
- Trace (
trace.jsonl): one JSON object per line, each with atype.header(bundle hash, seed, engine version, input source) comes first;bootmarks a boot or a load, andstageda change outside the input (a tour'shour), where a recording restarts;ticklines appear for ticks that had events, witht, the input frame, the player's state and the events;hashlines carry the state hash every 60 ticks;footerends 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>/.