Docs Design

Contents

M6 design: presentation depth

Status: draft, 2026-09-30. Read with ADR-0006 (template, theme and style are separate axes), ADR-0011 (asset tiers), research/asset-generation.md §6 and §8, and the look's code in engine/addons/gamewright/runtime/present/. M1 gave Gamewright one look done well (Tideline's lit ortho night coast) from code-built models and synthesized sound. M6 makes the look a thing the Director can change safely and widely: swap it without touching play, score music, and build figures and buildings from grammars instead of one builder per archetype.

1. Restyle, proven

The simulation never reads a Style: only views do (kit.ui's tokens, kit.audio's mix sheet, the look's profile). So a change that touches only presentation must leave every state hash exactly as it was. M6 makes that a gate, not a hope.

  • Presentation-only proposals (every operation's kind is view-only: stages.ts's viewOnlyKinds, the presentation stage's Style, Theme, Model, Sound, Sprite and Screen, or a later Score, plus CameraRig, which is authored with the systems because a first playable needs a camera but which only the view and the person's device input read; or a game manifest edit that changes only its style, theme, camera or flow, which is how the restyle tool swaps a look; or an Archetype edit that changes only its looks and the fields only views read, below) run L1 and L2 and are verified against their base: L1's state hash and every L2 scenario's final state hash must equal the base's, bit for bit. Any difference turns that rung red with restyle.changed_play, naming the first run that differs and the first trace checkpoint where it parts, and the report's restyle section lists every hash compared. The base's runs, L1 included, are cached by bundle hash and engine sources, so once a base has been run each proposal on it costs one ladder run, not two. A proposal stacked on others (after) is compared with its stacked base. (Before M6 such a proposal ran L0 only.)
  • Before and after frames at the same shots, side by side (image.ts's sideBySide, as the preview tool's sheet), go on each L3 frame of the report (before, beforeAfter), so the person sees the swap; the playtest tool returns them as images. L3 runs for a proposal only when asked (playtest {changeId, rungs: [..., "L3"]}); the base then runs it too.
  • A second style for Tideline (a warm dusk coast, or a grey storm) proves one swap re-looks the whole game: every model, the sky, the light, the UI and the mix.
  • A lint keeps the rule true (tools/check-presentation-reads.sh, beside the hidden-input check, in check-all and CI): simulation code, meaning runtime sim, rules and verify, every kit system, and every script they reach by class name, never names a view-only kind or reads a manifest field that names a view-only document. Views and runtime/present/ are not followed. No path from a look into play exists today, so the gate's red case is proven with a leak injected through the test engine (core/test/verify/proposal.test.ts).
  • Looks on an Archetype (built 2026-10-01). A Director making a platformer changed only how things look, every Archetype's sprite and the Model recipes, and the state hash moved even for one shade of a flat box. It filed a KitRequest ("Sprites that can change without changing play's state hash"). The cause: kit.core's spawn copied every component entry of an Archetype into the entity's state, kit.sprite's sprite and kit.actors' poses with the rest, so they reached saves, the state hash and the world digest, though nothing in play ever read or wrote them (views read them from the Archetype, GwKitSystem.read_settings). A Model recipe and the Archetype's model never moved a hash (no entity carries them), but an Archetype edit of any kind counted as play, so the gate never held such a change to its hashes.
    • The rule: a look is declared, per component, in kit.json ("presentation": true, in kit.schema.json). Declared rather than listed, so a new kit's look is covered by saying so; per component rather than per kit, because a kit with systems may still draw from a component of its own, and kit.sprite has neither systems nor views (kit.platformer's view and kit.ui draw its sprites), so "a kit with views and no systems" (KitInfo presentation, what turning kit.music on is judged by) would have missed it. A look keeps no state (the core refuses state on one, vocab.presentation), no expression reads it (expr.look; an expression in one runs in a view, so it may call pure functions only), and simulation code never names it: tools/check-presentation-reads.sh fails on a string naming a look in the same simulation code it already keeps off view-only kinds.
    • The engine (GwSim.looks, filled by the kit registry from every kit.json it finds) never gives an entity a look's state: kit.core's spawn skips it. Views read looks from the content, as they did. GwSim.load_state drops looks from a loaded state (dropped_looks), so a save written before still loads, into exactly the state today's engine reaches by the same play: the t6b fixture's save from before (thirteen entities with poses) loads, hashes as the same tour does now, and plays on the same (engine/tests/sim/gw_look_state_test.gd). Nothing is refused: no kit version or simulation version moved, so the world stamp holds, and a loaded old save's loaded_hash() is the hash of the state it loads as. A sprite one shade off, a sheet given, poses, the Model's palette and the model an Archetype names each keep the world digest and the state hash after every tick of a platformer level (the same test went red on the first three before the fix).
    • The gate (scopeOf): an Archetype edit is presentation when every field it changes is one only views read (name, model, cues, meta) or components whose entries, without the looks (KitInfo looks), are the same in the same order, and something it changes is a look or a look field. A look and a step of play in one edit is play. A template's Archetype the change makes the game's own is compared with the template's copy. In the real engine, platform_mini with every sprite and the hero's Model one shade off is presentation and keeps L1's hash and the routes Playtest's (core/test/look-engine.test.ts); with kit.core copying looks into state again it was red with CHANGED PLAY on L1 and L2.
    • What moved, once. A state hash moves only where a live entity carries a look, and each moved once, for this reason alone. Burrow Run (every Archetype has a sprite): its world digest (8bb02771… to cac18a1e…), L1's hash, both Playtests' and the routes tour's (0b63ca45… to dff613b7… at tick 477). Tideline (the sentry's poses): L1's hash, all five Playtests' and both tours'; not its world digest, as the sentry spawns on game.start, after the digest is taken. So do the fixtures whose entities carry one: the core's platform_mini, the planted invisible_enemy and lights_out, and the engine's t6b and tideline bundles. Nightcoast, Fog Signal and Pier Pressure carry no look on a live entity and kept every hash. No test, golden file or doc pinned a hash that moved. Play didn't change: on all five sample games the proof tour's trace (every tick's input, player state and events) is the same byte for byte apart from its hash checkpoints, with the same event counts, and all 71 Playtest runs (and each L1 boot) end with the same status, outcome, tick count and metrics.

2. Music: kit.music and the Score document

Research §6.7: a score spec renders to exact whole-bar stems that Godot layers and switches.

  • Score (score.schema.json, built): key, mode, tempo, meter, rate, seed; sections (id, bars, chords, when); layers (id, level, easeBars); and stems. The harmony lives on the section, as Roman numerals in the key (i VI III VII, I:2 IV V7: case is the quality, b or #, then 7, maj7, o, +, sus2 or sus4, then :bars), so a stem's generator plays over it: chords (triad, seventh, open or power, held or on a step rhythm like x---x-x-), an arp (up, down, updown or seeded random), a drone (root, root and fifth, or the triad; following the chord or the key), a euclid rhythm (drums as they are, or a bass on the root, third or fifth), a motif of scale degrees with one transform a bar (same, invert, retrograde, up, down, follow the chord), or explicit notes (a flat list of {bar, beat, note, length}, with note names like A3 or scale degrees: LLMs write these well when the grammar is small). Every stem renders to exactly bars × beats_per_bar × 60 / bpm × rate samples, from a seed path, so a score is deterministic and loops without a click (notes that ring past the end wrap to the start).

  • A stem is voiced by a Sound, not by a recipe written inline: the instrument is a Sound document made with a builder kit.json marks "notes": true (synth.layered and synth.sfx), so the Director writes instruments the way it already writes sounds, and the core refuses a voice that names no Sound (ref.unknown) or one that plays no notes, such as a synth.bed loop (score.voice_plays_no_notes). Each pitch renders once, as long as its longest note, and every note of it is the start of that render, gated: a sampler with sustain, exact because the synthesis is causal. Stems are baked by kit.audio's bank in a music mix-sheet row.

  • Layers, not tracks. Stems play together, each with a volume driven by an Expr over the simulation (tension: watched(self), night: 1 if game.hour >= 20 or game.hour < 5 else 0), eased per bar: a full swing takes easeBars bars. Self is the player, as on a Screen. The Expr language has no comprehension over entities, so the tension example the plan gave (max(e.suspicion for e in threats)) could not be written; kit.perception gained a pure watched(e), the most suspicion any watcher holds of a body. Sections switch on bar boundaries: at each bar line the first section whose when holds plays.

  • Web first: measured, and it changed the plan. The plan was AudioStreamSynchronized for the layers and AudioStreamInteractive for the sections. A probe (tools/heavy.sh tools/web/audio-probe/run.sh: a small Godot project exported without threads into build/audio-probe, played in headless Chromium, with every node that reaches the AudioContext's destination routed through an analyser and a level meter on the audio thread) found, on Godot 4.7.2 and Chromium (2026-09-30, twice, the same numbers):

    • AudioStreamWAV is the only stream of the four that can be a sample (can_be_sampled(): WAV true; Synchronized, Interactive and Playlist false). A WAV plays as an AudioBufferSourceNode and goes on through a 150 ms main-thread stall without a gap.
    • AudioStreamSynchronized and AudioStreamInteractive do play, but in Stream mode: no AudioBufferSourceNode is made; Godot's own mixer runs in its godot-processor worklet, fed from the main thread in a build without threads. Per-stream volume follows live (a 550 Hz stream at -80 dB is silent, at 0 dB heard), and switch_to_clip switches. But each went silent for five 21 ms blocks (about 110 ms) when the main thread stalled for 150 ms, which a load, a bake or a slow frame does. And a WAV clip carries no bpm, so "next bar" has no bar: the switch landed 0.27 s after the call, wherever that was in the bar.
    • Two AudioStreamPlayers with WAVs started in the same frame are sample-aligned: a click train and its negative summed to a peak of 0.0000 over 3 s. (The same pair inside one AudioStreamSynchronized did not cancel: peak 0.64.) play(from_position) starts a WAV where it is asked (its first position read 0.5), and a 22050 Hz WAV plays at its pitch.

    So kit.music uses neither: each stem of a section is a looping AudioStreamWAV on its own AudioStreamPlayer, a section's players start in one frame, a layer is a player's volume_db, and a section switches at a bar line by starting the next section's players where the bar grid says they should be and fading the old ones out over a few frames. That is the fallback this section planned, with players in place of the synchronized stream, on the desktop too (the desktop may add, never differ).

  • Proof (built; the sample is core/test/fixtures/games/score_mini, the tiny fixture with five instrument Sounds, a two-section Score and a HUD, baked to engine/tests/fixtures/score_mini): every stem is exactly 211680 samples (4 bars × 4 × 60 / 100 × 22050); every seam ratio is 1.0 or less (0.21 at worst; 1.94 when notes are cut at the loop's end instead of wrapping); the same score and seed give the same bytes, another seed changes only what it should; a layer follows var.score planted in the simulation, eased per bar; a section switches only on a bar line; the same tour ends on the same state hash with the music view presenting and without views (a planted write turned it red); the offline mix of each section is 19.200 s for two loops at -25.2 and -23.4 dBFS RMS, the loops byte-identical, and its chords measure as written (D, A, F under i; G, C, E under VII). In the browser (tools/web.sh --listen=SECS, which taps the AudioContext's destination and fails a game that stays or falls silent; the silent toy bundle failed it), the score plays through AudioBufferSourceNodes in Sample mode and holds in every window; on SwiftShader its first section took about 4 s to bake after the engine was ready, a pitch or a stem a frame. And a proposal adding the Score and its Sounds to the pack is presentation-only and keeps both of its state hashes in the real engine (core/test/score-engine.test.ts; a Score read planted in kit.core turned it red).

  • M6c (built). A Style chooses its score (audio.score; else the first Score by id), and every audio view reads the Style the manifest names (GwStyle.doc_of): kit.audio had read the first Style with a mix, so Tideline played its template's beds and lost its fire row. A restyle that swaps the score keeps every state hash in the real engine; a Style read planted in kit.core turned it red. Turning on a kit that registers views and no systems, with what it requires (kit.music, kit.audio), is presentation too, so a first "add music" proposal meets the hash gate. Tideline has a score (examples/tideline/music/tideline.score.json, A minor at 72 under the night bed's A): a drone always; a dark pad with nightfall() and a plucked day phrase against it; a warm bell arpeggio while lamp_lit(self); a pulse that rises with watched(self); and a hunt section, i against bII with a thud and a tick, while the sentry is sure (watched(self) >= 1). Adding it kept all six state hashes (L1 and five Playtests).

  • The bake, measured, then cut finer (M6c). Before: a pitch or a stem a frame, and a section started when all of it was baked. The costliest step was a pitch's echo and room: bake fills every synth.layered recipe's echo and reverb with their defaults when it names none, and they are two thirds of a pitch's render (270 of 410 ms for a 14.75 s render of Tideline's drone, the room four fifths of that); then a stem's bake (90 to 450 ms). Now a step is about one pass over the samples (a pitch a pass at a time, its echo and room a chunk at a time, a stem a note at a time, then its bake's passes), a frame does as many as a third of the last frame's time allows (10 to 40 ms), a section starts once its first stem (its lead) is baked, and the rest join together on the lead's grid. Every bake at once is the same run to its end: the sound bank's golden hashes, every stem's pinned bytes and 104 bakes with 79 far copies compared with the code before all held. Numbers, three runs each on a busy box (load 20 to 34), each frame lasting its work or 16.7 ms:

    before after
    Tideline, first sound 4.4–7.1 s (all of coast) 1.7–2.1 s (the drone)
    Tideline, all of coast 4.4–7.1 s 5.8–7.8 s
    Tideline, longest frame 504–928 ms 74–178 ms (34–72 ms on a quieter run)
    Tideline, frames over 50 ms 29–43 0–8
    score_mini, first sound 2.4–3.2 s 1.9–2.7 s (its pad leads, the costliest stem)
    score_mini, longest frame 284–352 ms 47–66 ms
    browser, Tideline, first sound after ready 27.5 s 21.8 s (the drone; all of coast 32.4 s)
    browser, longest frame of baking 810 ms 92 ms

    Everything finishes about as late as before (6.9–9.8 s natively, 33.9 s after ready in the browser, against 5.2–8.1 and 33.8): the work is the same, spread so no frame stalls.

  • Joining on a grid. Played from the lead's get_playback_position(), a stem lands a mix block off natively (256 frames, 5.8 ms, on the dummy driver) and about 12 ms off in the browser. Joiners therefore start together in one frame (sample-aligned with each other, P4), silent; once the mixer has played them the gap is read and they start again that much earlier and fade in (GwStemJoin). Natively that lands them within 0.25 ms of the lead (a sine and its negative, gw_music_native_test.gd); in the browser Godot's positions are too coarse to correct by (the probe's P8: 13 ms played plainly, 11 and 13 ms settled), so a joined group stays about 12 ms off a held lead, which isn't heard. A section switch starts its stems the same way, a block off the old grid, under the old section's fade.

  • kit.audio bakes in passes too (built). Profiled natively, binding kit.audio's view was the biggest part of Tideline's start: it baked the day and night beds and every alive loop synchronously (10 s), then baked one whole Sound in each frame of play (up to 3.6 s for a template bed nobody hears). In the single-threaded browser the page drew nothing for 35 s after the click, about 20 s of it this bind. Now binding synthesizes nothing:

    • The same machinery as the music. A Sound bakes as a GwSoundRun: synth.layered through GwLayeredRender, synth.bed through GwBedRun (each layer's passes: its noise, each filter, its level curve, a partial or a saw at a time, a surf wash at a time; the echo and room a chunk at a time), synth.sfx in one step (3 to 15 ms for a click), then GwBakeRun's passes. GwSoundBank.render and baked() are those runs to their end, so the bytes cannot drift: the golden hashes held, and so did every variant and far copy of the fixture, baked from a stepped queue that one-shots interrupt mid-bed, against the code before (gw_sound_bank_test.gd's FAST_PCM/FAST_FAR, made at 173ca32), and all 60 Sound variants and 46 far copies of Tideline and Nightcoast, with every measurement the bank reports.
    • What is heard first bakes first (GwBakeOrder): clicks that cost next to nothing (a synth.sfx under a second: a title's menu works from the first frames), the beds heard at the hour the game starts, alive loops in earshot nearest first, one variant of every one-shot a document names cheapest first (GwCueCoverage.emitted, GwSoundRun.estimate), the Style's other bed, the other variants, then every other Sound (another Style's beds, a Score's instruments). A bed the hour turns toward, a loop that comes into earshot and a one-shot asked for move up. A bed or loop starts once it is baked, fading in over 0.25 s.
    • One budget for every view that bakes (GwBakeBudget on the stage). A third of the last frame, 10 to 40 ms, as M6c set it, now shared by kit.audio and kit.music, so baking keeps to a third of a frame however many views bake. Each view says how soon its next work is heard: a score's lead (and a free click) first, then what should already be sounding (the bed, a loop in earshot, a one-shot just asked for), then the rest; only the soonest level bakes, shared evenly. The share is a credit: a bed's pass runs tens of milliseconds where a note runs one, so when views share a level a step past the share is paid back with skipped turns, and coarse steps can't crowd out fine ones. A view alone at its level owes nothing and bakes every frame, as M6c's music did (gw_bake_budget_test.gd pins both). Measured on score_mini, whose music must start before its game turns busy 6 s in: audio first with music at least one step a frame started it 11 s after bind instead of 2; an even split by time, 10 s, since every frame still carried a 50 to 90 ms bed pass; levels and credit, 3.2 s; but with the credit binding a view alone too, a run at load 42 took 11.8 s (a lone view's coarse steps were charged against nobody). Now Tideline's drone starts 1.8 to 2.3 s after the first frame at load 27 to 41 (M6c: 1.7 to 2.1), and Nightcoast's bed sooner than before.
    • A one-shot asked for before it is baked plays nothing (and logs it once: audio view: <cue> asked for N ms after bind before it was baked…), unless another of its variants is baked, which stands in. It moves up to bake as soon as what is heard now has. Baking it at once would stall that frame for its whole bake, measured natively for every Tideline one-shot: 120 to 330 ms each, the sentry's call 730 to 980 ms (every synth.layered recipe carries the default echo and room tails, so a 0.2 s footstep is 1.3 s of samples), and the browser bakes about 1.5 to 2 times slower (all of Tideline's audio: 55 to 58 s after bind natively, 89 to 110 s in the browser, both at a third of each frame). A 0.4 s freeze is a hitch anyone sees; one missing footstep in the first seconds is not missed. Every one-shot the documents name has a variant baked 20 to 32 s after bind natively and 40 to 49 s in the browser, and Tideline boots on its title, where the clicks are ready at once and the bed and the drone bake while the title shows. A test runs the sentry fight bare and heard from an unbaked bank (six one-shots missed in its first 1.3 s): the same state hash. So does Tideline's proof tour in the player, which leaves the title after a second and missed five one-shots (the first footstep at 5.5 s), against the same tour headless with no views: sha256:8f68c0f8… at tick 4591 both ways; the night bed moved up and started as the tour's clock turned toward dusk.

    Numbers (Tideline and Nightcoast bundles, the profiler binding every view and then presenting them at up to 60 frames a second; then tools/web.sh --listen in Chromium with V8's default tiering; two runs each after, at load 27 to 41, and the before at load 15 to 30 (one browser run); times from the start of kit.audio's bind; "longest frame" is the longest frame the audio view took):

    before after
    Tideline, kit.audio's bind 9.4–10.1 s 6 ms
    Tideline, first sound 13.4–14.9 s (the bed) 4.2 s (the drone)
    Tideline, the bed starts 13.4–14.9 s 9.9–10.8 s
    Tideline, longest frame 2.3–3.6 s (bind 9.4–10.1 s) 185–188 ms
    Tideline, frames over 50 ms 49 of 50 77–102 of about 2850
    Tideline, every Sound baked 31–41 s 58 s (past 60 s on the other run)
    Nightcoast, kit.audio's bind 4.7–5.4 s 3 ms
    Nightcoast, the bed starts 5.3–6.2 s 3.9–4.7 s
    Nightcoast, longest frame 128–147 ms (bind 4.7–5.4 s) 117–182 ms
    browser, Tideline, ready (from loading the page; the click is at 0.3 s) 35.3 s 16.4–17.9 s
    browser, the same bundle without kit.audio and kit.music, ready 16.2 s
    browser, first sound after the AudioContext ran 37.3 s (the bed) 17.5–19.9 s (the drone)
    browser, the drone starts (from loading the page) 54.2 s 19.0–21.4 s
    browser, the bed starts (from loading the page) about 37.6 s 26.0–29.5 s
    browser, longest frame of baking the bind (about 20 s), then a whole Sound a frame; music 138 ms 202–504 ms (audio), 131–146 ms (music)

    In the browser Tideline is ready as soon with its sound as without it: what is left of its start is the world (generation and the world view's bind), not audio. Without Liftoff (--js-flags=--no-liftoff, one run at load 21 to 30) the engine's first line came at 22.9 s instead of 1.6 s, ready at 53 s, and the drone and the bed later too: on this box the bake gained nothing from skipping V8's baseline tier. Nightcoast has no score, so its bed is its first sound, and baking alone it gets every frame: it comes sooner than before while the game draws and answers from the first frame. The longest frames left are single passes over a 16 s stereo bed (pink noise, a wander curve, the bake's loudness measure over both channels: 50 to 75 ms natively on their own, two to three times that in the browser and under load); cutting those into chunks, as the echo and room already are, is the next step if they show.

  • Sound in the 2D games (2026-10-09, the audit's Wave 1). kit.audio heard only events that name an entity, and a platformer's jump, a grid's turn and a story's line name none, so the three 2D templates left kit.audio out. Now:

    • The Style's audio.cues {on, cue} play an event's sound once, at the listener.
    • An event's arch field plays that archetype's cues when no entity of it is named (platformer.collected; kit.grid's new grid.moved {arch}, once a turn per kind that moved, so a pushed crate scrapes).
    • audio.beds.scenes {when, bed} put a scene's bed in place of day and night (GwBedMix): the whens are read every frame on the view's pure VM, and a change crossfades at equal power over fadeSec.
    • Beds add by power per Sound. A Style that named one bed for day and night (Thornwick, Saltwell, the adventure and twin-stick templates) had it silent by day: the night's zero gain was written last.
    • The one-shots heard in the first seconds bake among the first (GwBakeOrder's QUICK: 22050 Hz, or 11025 for a bell, no echo or room tail, 0.12 to 0.45 s), and each score leads with a one-pitch drone on the key. At game pace in the player (offscreen, load 15 to 38) the music started 1.9 to 7.0 s after bind and the first bed 6.1 to 18.7 s. A bot that solves a room in its first seconds still misses a few one-shots; a person doesn't.
    • The player's log ends each game with what kit.audio played (GwView.finished: audio view: played snd.jump x29, snd.land x28, …).

3. Figures from a rig grammar

Today each moving thing has its own builder (figure.person, machine.sentry), and kit.actors poses whatever a rig names. M6 replaces per-archetype builders for moving things with one rig grammar:

  • A figure.rig builder: a skeleton from a small set of plans (biped, quadruped, hexapod, serpent, floater), proportions as numbers (head ratio, limb length and thickness, stance), and parts from kit.mesh's primitives hung on bones, with palette slots.
  • Procedural gait: legs placed by phase offsets per plan (biped alternate, quadruped walk/trot/gallop, hexapod tripod), step length and height from speed; feet planted on the ground by height_at. kit.actors' pose rules (walk, run, crouch, windup, strike, dodge, hurt, dead) map onto the grammar's pose set, so an existing Archetype's poses keep working.
  • The existing builders become presets of the grammar (the person and the sentry rebuilt from it must look the same in shots, within the frame-compare tolerance), and are kept until the presets match.
  • Built first, and smaller (§6): a rig as data. mesh.parts takes joints, and any rigged Model takes poses made of keyed moves, so the grammar's plans and gaits can be presets of those.
  • Then the plans and gaits themselves (§7), as data: body plans in the library, each with roles a Model's joints play and presets made of those same moves.

4. Buildings from a grammar

A building.grammar builder: a footprint (rectangle, L, courtyard), floors, a roof kind (flat, gable, hip, shed, ruined), openings by rule (doors on the street side, windows every N metres), materials from the Style's palette, and wear (broken, overgrown) as numbers. Walls snap to the world's tile grid so kit.world_tiles can block movement and sight exactly where the walls are. It serves towns, bunkers and ruins, and it's the first builder a city or colony template needs.

5. Tiles and UI themes

  • Procedural ground tiles from the Style (noise, palette snap, edge blending per tile kind), for the 2D templates the sprite baker serves.
  • UI themes generated from the Style's ui tokens (already partly: GwUiTheme), extended with the curated OFL font set and nine-patch frames drawn at whole-pixel multiples.

6. Sprites that move

A Director made Acorn Rooftops, a fox platformer, and drew its fox, crows and acorns as mesh.parts Models. Each sprite was one still frame, so the fox glided over the roofs and the crows slid. It filed a KitRequest rather than fake it ("rigged animal and bird models for sprite": a creature.quadruped builder with poses, and a creature.bird). Burrow Run's hero is a figure.person, so it did run, but it jumped and fell in one frame of its run: figure.person has no jump pose, and nothing in a pack could give it one.

kit.sprite could already render clips: a Sprite's anims sample a Model's poses into frames (the hero's run is six frames of figure.person's run). What was missing was a way to say in data how a thing moves. Only builders in GDScript made poses, and only two builders did.

Where motion lives

Three documents, each saying one thing:

says written as
Model how the thing can move: its joints and its poses joints (mesh.parts), poses and moves (any rigged builder)
Sprite which poses become which clips, how many frames, how fast anims: {name, pose, frames, fromT, toT, fps, loop} (as before)
View which clip a body plays now, and which frame kit.platformer's view, by the body's state; kit.ui's sprite widget, by its anim

Poses belong on the Model, not the Sprite, because a 3D view poses the same Model (kit.actors' pose rules name poses), and because one pose can feed several clips (jump and fall are two ends of one arc). Clips belong on the Sprite, because frame counts and rates are about pixels.

Poses as keys on joints, not frames as pictures

The Director can't draw, and Gamewright content is documents, not images. Frames given directly would mean pixel grids in JSON: unreadable, unrestylable, one size only. So a pose moves joints, and the baker renders it, as it already did for figure.person:

  • Joints (mesh.parts only: other builders bring their own rig). A flat list, {id, parent, at}: a point the parts that name it (Part.joint) turn about, with a parent joint, so a paw rides a shin rides a thigh. Every rigged model has a root joint: mesh.parts's is at its feet and carries every part that names no joint; figure.person's is its hips, machine.sentry's its body. Parts are still drawn where they stand at rest; a joint is only a pivot. A mesh.parts Model with no joints and no poses is built exactly as before.
  • Poses, {name, loop}: a looping pose is one cycle (a run's two strides); a one-shot runs once from its first key to its last. A pose a builder already makes is replaced by the Model's of the same name; a new name adds one. So figure.person gains jump and fall in data.
  • Moves, a flat list, {pose, joint, swingDeg, rollDeg, turnDeg, forwardM, upM, stretch, phase}. Each channel is a list of keys spread evenly through the pose: four keys of a loop sit at 0, ¼, ½ and ¾ of the cycle; two keys of a one-shot at its start and end; one key holds. Between keys the value follows a Catmull-Rom curve through them (wrapping round a loop), so a four-key swing [35, 0, -35, 0] is nearly a sine and a frame that falls on a key shows it exactly. swingDeg turns about the side view's axis (positive swings a hanging leg forward and lifts a part that points forward), rollDeg about the model's length, turnDeg about the vertical; forwardM and upM shift the joint (a crow's head thrust, a body's bob); stretch scales it along its vertical and keeps its volume (1.2 is a fifth taller and thinner: squash and stretch). phase starts a looping move part way through its keys, so the four legs of a gallop share one list of keys and differ by phase.

Why keys rather than expressions: a gait is a shape over a cycle (a quick forward swing and a slow push back), which a list of four to eight numbers says directly and a sum of sines says badly; Expr is for simulation values and would invite reading play. Why not the KitRequest's creature.quadruped and creature.bird: they would throw the Director's own fox and crow away for a builder's, and every new body plan (a rabbit's hop, a frog, a bat) would wait for engine code. Joints on mesh.parts make anything the Director can draw move, and the rig grammar (§3) can later offer presets made of the same keys.

The core checks rigs at L0: a joint's parent exists and the joints are a tree, ids are unique and none is root; a part's joint, a move's joint and a move's pose exist; no joint moves twice in one pose; and a Sprite's clip names a pose its Model has (the builder's own, which kit.json now lists with its joints, or the Model's). A clip name no view of the game plays (a typo, runing) is a warning.

Which clip, which frame

A view never writes the simulation, so it chooses from what it can read. kit.platformer's view reads each body's platformer_body state:

the body is clip falls back to the frame
hurt (stunned) hurt idle from the hurt's first tick, at the clip's fps
climbing climb idle by height climbed: two frames a tile, still when it stops
in the air, rising jump fall, run, idle through the arc: take-off to the top, by vertical speed
in the air, falling fall jump, run, idle the top to full falling speed, by vertical speed
on the ground, moving, a patrol walk run, idle by the tick, looping
on the ground, moving run walk, idle by the tick, looping
still idle the first clip by the tick, looping

A looping clip whose state isn't tied to a start plays by the game's tick, each body offset by a share of the cycle drawn from its id (GwSeed.derive), so a row of crows doesn't strut in step. So the frame is a pure function of the tick, the body's state and its id: a shot at a named tick is the same frame on any machine, after a load, and with any number of frames drawn between ticks. A one-shot clip that isn't a jump, a fall or a hurt holds its last frame. kit.ui's sprite widget plays the clip its anim names by the game's tick, as it did.

The views say this in kit.json (clips on a view, beside its palette), so the vocabulary lists the clips each view plays and when, and a Director asked for "a fox that runs" finds run on the platformer view and the joints and moves that make one.

Looks, not play

A Model and a Sprite are view-only kinds, and a view reads poses, clips and body state without writing any of them. A change to joints, poses, moves or clips is presentation: the restyle gate holds every state hash. Nothing new enters an entity, a save or a hash. The baker is the same CPU rasterizer, so a sheet is the same pixels headless, windowed and in the browser; models and sprites that name no joint, pose or move bake to the bytes their golden hashes pin.

As built (2026-10-01)

  • Schemas. kit.mesh's structs Joint, Pose and Move; Part.joint; mesh.parts takes joints, poses and moves, figure.person and machine.sentry poses and moves. kit.schema.json gained joints and poses on a builder (what figure.person and machine.sentry build) and clips on a view (kit.platformer's platformer_view lists its seven with when). Codegen carries them to vocab.schema.json and both languages.
  • Engine. GwPoseTrack (runtime/present) evaluates keys; GwModelData evaluates a pose's tracks beside its old sine keys, with per-bone shifts and stretches that bone_transforms (the baker) and GwModelNode.set_pose (a Skeleton3D) both apply, the same bones within 1e-5. GwPoseRecipe (kits/mesh) turns a Model's poses and moves into tracks; mesh.parts adds a root bone and a bone per joint, parents first whatever the list's order. GwPlatClips is the view's choice of clip and frame; GwPlatformerView.frame_of(e) says what an entity shows. GwSpriteSheet gained rect_at and meta. The old pose path is untouched: every builder's golden hash and every pinned sprite sheet (the platformer fixture's hero, crawler and gem) is the same bytes.
  • Core. core/src/game/rigs.ts (rig.joint_root, rig.joint_twice, tree.unknown_parent, tree.cycle, rig.unknown_joint, rig.unknown_pose, rig.pose_twice, rig.move_twice); checkSprites adds sprite.unknown_pose (error) and sprite.clip_unplayed (a warning, the played names being every clip a view of the game lists plus every Screen sprite widget's anim). The vocabulary index shows a view's clips and a builder's joints and poses.
  • Samples. Acorn Rooftops' fox (seven joints, five poses, 31 moves) gallops, leaps, falls and flinches; its crow (seven joints, three poses, 12 moves) struts. Burrow Run's hero has its own run, jump and fall over figure.person's joints (31 moves). Every state hash and world digest of both packs' L1 and L2 runs is the same as before (23 and 25 hashes compared). Looked at as contact sheets of tour shots three ticks apart, in the game: the fox's gallop reads as a gallop (legs reaching, then gathered under), the leap as a leap (stretched nose up, flattening at the top, reaching for the roof as it falls), the crow steps in turn with its head bobbing, and the hero runs, throws an arm up to jump and falls with both arms up.
  • Not done. preview can't show a Sprite's whole sheet (a preview is one frame of play), so a Director sees one frame of a clip at a time; a run that steps with the ground covered instead of the tick (feet slide a little at the fox's 8.5 tiles a second); a landing squash (the body keeps no landing tick to start one from); and a one-shot clip in a kit.ui sprite widget holds its last frame, as before, since a screen keeps no tick its value changed on. The KitRequest's creature.quadruped and creature.bird stay unbuilt by choice (above); the rig grammar's presets came next (§7).

7. Motion presets: body plans

Sprites moved, but every key was the Director's to write. The fox's gallop, leap, fall, flinch and breath took seven joints and 31 moves; the crow's strut twelve more; Burrow Run's hero 31. That is a lot to get right through tools alone, and each new animal started from nothing. The rig grammar (§3) planned presets. They are data: a body plan names the parts of a body a joint can play, and offers named gaits and poses written as the same keyed moves a Model writes, by role instead of by joint. A Director says "this is a quadruped: these joints are its legs, tail and head; give it the gallop, the jump and the hurt", then changes what it wants.

A body plan

A BodyPlan document (bodyplan.schema.json, <slug>.bodyplan.json, ids plan.*):

  • roles: {role, required, description}. A role is a pivot: a leg's is the joint at the top of the leg, a head's the joint at the base of the neck. A required role must be played by a joint of every Model on the plan (a quadruped's body and four legs); the others are moved when a joint plays them and skipped when none does (a tail, a knee). A role named root is every rigged model's first joint, so a bird's hop lifts it legs and all (its legs hang from the root, not the body).
  • presets: {name, loop, description}, a gait that loops or a one-shot, described with the clip a view plays it as (a gallop is a fast animal's run).
  • moves: a flat list, {preset, role, swingDeg, rollDeg, turnDeg, forwardM, upM, stretch, phase}, read exactly as a Model's moves are (§6), at most one per role and preset.
  • sizeRoles and sizeM: the roles whose joints' mean height is the body's size (its legs: leg length), and the size the moves were written for.

Left and right are the body's own: models face +X, so +Z is the right side, the side a side view shows nearest. The library's plans (library/plans/):

plan roles (required in bold) presets written for
plan.quadruped body, head, tail, front_left_leg, front_right_leg, hind_left_leg, hind_right_leg, and a shin (knee or hock) under each leg idle, walk (four-beat, lateral), trot (diagonal pairs), gallop, hop (a rabbit's bound), jump, fall, hurt the fox: legs 0.42
plan.bird root, body, head, tail, left_leg, right_leg, left_wing, right_wing idle, walk (a plain walk with a roll), strut (the head thrusting and holding), hop (both feet), flap, jump, fall, hurt the crow: legs 0.3
plan.biped hips, spine, head, left_thigh, right_thigh, and each side's shin, foot, arm and forearm idle, walk, run, jump, fall, hurt figure.person at its default height: hips 0.6

The quadruped's idle, gallop, jump, fall and hurt are the fox's own moves by role, the bird's idle, strut and hurt the crow's, and the biped's run, jump and fall Burrow Run's hero's: the samples were looked at as contact sheets already (§6), and now any body of the plan has them. The rest were written for this and looked at the same way (As built, below). A snake or a fish would be a plan of the same shape, a chain of segments with a wave by phase; neither is in the library yet.

How a Model takes them

Every rigged builder (mesh.parts, figure.person, machine.sentry) takes three more fields:

  • plan: the body plan's id. Left out, the builder's own: kit.json's plan on a builder says which plan its skeleton fits (figure.person's is plan.biped, machine.sentry's plan.quadruped), and mesh.parts has none, so its Model names one.
  • roles: {role, joint}, which joint plays a role. A role it leaves out goes to the builder's own map (kit.json's roles on a builder: figure.person's left_thigh is thigh_l), else to the joint of the role's own name. So a mesh.parts animal whose joints are named for their roles, as the fox's and the crow's now are, maps nothing.
  • presets: {preset, pose, amount}, a preset taken as a pose, named pose or the preset's own name ({"preset": "gallop", "pose": "run"}: the fox's run is a gallop; or a Sprite's run clip names the pose gallop). amount multiplies every angle and shift, and a stretch's distance from 1: 0.6 is a gentler gait, 1.3 a bigger one.

Then the Model's own moves change it: a move naming a preset's pose replaces the preset's move for that joint (a move with no keys holds the joint still), and its own poses add others. A preset's pose replaces a builder's pose of the same name, as a Model's own pose does, so figure.person's Model takes the run, jump and fall it lacked as three lines. The fox:

"plan": "plan.quadruped",
"presets": [{ "preset": "idle" }, { "preset": "gallop", "pose": "run" },
            { "preset": "jump" }, { "preset": "fall" }, { "preset": "hurt" }],
"joints": [{ "id": "body", "at": [-0.04, 0.44, 0] },
           { "id": "head", "parent": "body", "at": [0.26, 0.5, 0] },
           { "id": "front_right_leg", "parent": "body", "at": [0.17, 0.42, 0.06] }, …]

Scaled by the body

A plan's angles are the same for every body: a gallop swings a leg 50° forward on a fox and on a horse. Its shifts are not: a fox bobs 0.07 in its gallop, which would throw a rabbit off its feet. So every forwardM and upM key is multiplied by the Model's size over the plan's sizeM, the size being the mean rest height of the joints playing the size roles (the legs), to a thousandth. The rounding matters: rest points are single floats (0.42 is 0.4199999869), and without it a fox the plan was written for would scale by 0.99999997 and its frames would differ from its own moves by a pixel here and there. Burrow Run's hero, 1.45 tall, bobs 1.082 times the plan's.

Where presets live

Plans are documents in library/plans/, versioned with the engine as templates are, and a pack may hold its own (plans/<slug>.bodyplan.json): one with a library plan's id replaces it for that game, and one with a new id is a body the library has none for (the platformer fixture's flag waves by its own plan.pennant). The core loads the plans a game's Models use, by their plan or their builder's, and bake writes them into content.json with every other document, so a bundle carries every plan it plays and nothing outside it. A newer library changes a game's motion only when it is baked again; a game that takes no presets bakes the bytes it did.

The engine writes the presets out: GwMeshBuilders.build finds the plan in the game's content (plan_of), and GwPoseRecipe asks GwRigPresets.expand for the poses and moves the presets come to on the built skeleton, the moves a Director would have written joint by joint, before the Model's own poses and moves, so the Model's win. The builder cache and the sprite cache key on the plan document as well as the recipe.

Why documents, not kit.json registrations: a plan is presentation data like a Model, so a pack can add the body the library lacks (a frog, a bat) without engine code, as §6 meant mesh.parts joints to. Why the engine and not bake writes them out: a size comes from the skeleton the builder builds, and figure.person's joints exist only in GDScript, from its height and build.

How a Director finds them

vocabulary lists every plan, the library's and the game's, with kit.mesh's entries as kind plan: the index line names its roles and presets; {"kind": "plan"} gives each in full, its presets described and its moves by role, so a Director can see what the gallop does to the tail before it changes the tail. A builder's entry carries its plan and roles.

Checks (L0)

  • A plan (plan.*): roles and presets named once (plan.role_twice, plan.preset_twice); moves and size roles name what the plan has (plan.unknown_preset, plan.unknown_role); a role moves once per preset (plan.move_twice).
  • A Model (rig.*): a plan to take presets from (rig.no_plan), one the library or the game has (rig.unknown_plan, with the nearest); roles the plan has (rig.unknown_role), each played once (rig.role_twice) by a joint the model has (rig.unknown_joint); every required role played (rig.unmapped_role, which says how); presets the plan offers (rig.unknown_preset); no two presets one pose, nor a preset's pose among its own (rig.pose_twice). A preset's pose is a pose its moves and a Sprite's clips may name.

Looks, not play

The poses are a pure function of the documents and the built skeleton: no seed, no clock, no order beyond the lists'. BodyPlan is a view-only kind, as Model is, so a change to a plan or to what presets a Model takes is presentation and the restyle gate holds it to every state hash. Nothing new enters an entity, a save or a hash.

As built (2026-10-02)

  • Schemas. bodyplan.schema.json (a content kind: the change set's Document union, the presentation stage, view-only); kit.mesh's RoleJoint and PresetUse and the plan, roles and presets fields on mesh.parts, figure.person and machine.sentry; kit.schema.json's plan and roles on a builder; the vocabulary tool's kind plan. Codegen carries them.
  • Core. game/load.ts loads library/plans and merges the plans a game's Models use (planOf: the Model's, else its builder's when it takes presets), a game's own winning; game/plans.ts checks plans and preset uses; rigOf counts a preset's pose as the Model's; checkRefs names an unknown plan rig.unknown_plan; the digest lists plans with kit.mesh. A game that takes no presets bakes the same bundle: Fog Signal, Lamplighter and Nightcoast bake to the folders (f1360a5e4ba3, be7235b32554, 0061192f1afb) main's own builds have.
  • Engine. GwRigPresets writes presets out as moves; GwPoseRecipe.add takes them first and is now called once, by GwMeshBuilders.build, rather than by each rigged builder (the same steps in the same order: every builder's golden hash and pinned sprite sheet holds); GwMeshBuilders.plan_of finds the plan in the game's content for the builder cache and the sprite cache, which key on it.
  • Samples. Acorn Rooftops' fox takes five presets and its crow three, their joints renamed for their roles: the fox's Model went from 377 lines to 212, the crow's from 242 to 176, and both sprite sheets bake the same bytes as their hand-written moves did. Burrow Run's hero takes three: 224 lines to 62; its run bobs 1.082 times as much, which no frame shows. Every L1 and Playtest state hash and world digest of both packs is the same as before (23 and 25). The platformer fixture's flag waves by its own plan.pennant, so a game's own plan is exercised end to end. Acorn Rooftops' web proof tour ends on the desktop's hash.
  • A creature from presets alone. A hare (mesh.parts: a round body over big haunches, long hind feet, short forelegs, two upright ears, a white scut; its joints named for plan.quadruped's roles) took idle, hop as its run, jump, fall and hurt, and nothing else: no poses, no moves. Played in a scratch copy of Acorn Rooftops in place of the fox, it ended both tours it was shot in on the fox's state hash. It is kept as an engine test's fixture (engine/tests/fixtures/rig/hare.model.json), where every quadruped preset must move it.
  • Looked at. Contact sheets of tour shots two or three ticks apart, in the game: the fox gallops (reaching, gathered under, airborne), leaps (stretched nose up, flattening, nose down reaching for the roof) and flinches up and back when it runs into a crow; the crow struts, its head thrusting; the hero runs, throws an arm up to jump and falls with both arms up; the hare hops (stretched long, then bunched with its hind feet past its forelegs), leaps and flinches. Every preset of the three plans was baked on its sample (and the quadruped's on the hare and a machine.sentry) and looked at as sheets: the walk four-beat, the trot in diagonal pairs, the bird's hop and flap, the biped's walk and hurt.
  • Not done. A snake or a fish plan; a tool that writes a Model's presets out as moves, for a Director who wants to start from them and edit every key (the vocabulary shows the plan's moves, which is most of the way); presets that adapt to speed (a gait's rate is its clip's fps, as before); and feet planted by the ground (§3's height_at).

8. Juice: kit.vfx

The audit's Wave 4 (docs/design/audit-2026-10.md, "No VFX at all"): a hit, a pickup, a landing, a death and a solved room happened in silence for the eyes. Games made here should feel alive when something happens, as the best tools' games do, without one line of game code.

Where it is written

In the Style, as vfx.effects, beside audio.cues: each effect answers an event (on) at the entity or place its at names, with any of four parts. A new document kind was the other choice; it would have been a second document to restyle, a second id to pin, and a kind the Director learns for one block. Juice is "looks like" (ADR-0006): a restyle that swaps the palette should swap the sparks with it, in one document. An Archetype-level map (as Archetype cues are) was left out: at already says whose it is, and one map per game keeps the schema small.

  • particles: count, speed, spread (a cone round straight up), lifeSec, size, gravity (positive falls), drag, shape (square or spark), lift, colours from palette slots, and trailSec, which lets them out one by one from where the entity is then (a shot's trail).
  • flash: colour, seconds (at most 0.3), strength.
  • shake: strength as a share of the view's height (so a 2D and a 3D game read the same number), seconds, falloff (fade, sharp, none), hz.
  • tween: squash, stretch, pop, hop or grow, an amount and seconds, standing on its feet.
  • at: an event field (an entity, an entity's id, a vec3) or player; left out, the first entity the event's kit declares, else the player (a platformer's jump and a grid's win name none). The core checks the field is one that says where (vfx.at_unknown).
  • Each part names what it is (particles their count, a flash its color, a shake its strength, a tween its kind). Bake fills in an absent optional object whose fields all have defaults (defaults.ts), so a part with nothing required would be added to every effect: the first shots had every landing flash and shake. core/test/game.test.ts bakes a squash-only effect and checks it stays one.

How it draws

  • Particles are worked out, not stepped. Godot's CPUParticles and GPUParticles step with the frame's delta and draw from their own random numbers: a tool run draws no frames between its shots (docs/design/fast-runs.md), so they would never show in a proof shot, and ADR-0007 §6 keeps GPU particles out of verification frames. Each particle here is a closed form of its age: launched in a direction and at a speed drawn once from hashes of the spray's seed and its index, slowed by drag, pulled by gravity, shrinking to nothing; the seed is GwSeed.derive(root, "vfx/<on>@<at>#<n>/<entity>/<tick>"). A shot at a named tick shows the same sparks mid-flight on every machine, and nothing reads the clock or the global random numbers (tools/check-hidden-inputs.sh).
  • 3D: one MultiMeshInstance3D of unshaded square quads, each turned to face the camera on the CPU (a spark lies along its streak as the camera sees it), opaque, its colour through the colour door. Particles die by shrinking, so nothing is transparent and nothing needs sorting. The shake moves the rig's h_offset and v_offset in whole texels, so the world never crawls.
  • 2D: the flat views draw the particles themselves (GwJuice.draw_flat), on the art's pixel grid, and shake the level by whole art pixels; the HUD never shakes.
  • The contract is GwJuice in runtime/present: kit.vfx fills it, and the views that draw entities read how each entity looks now (GwJuiceLook: width, height, lift, flash). Without kit.vfx a stand-in changes nothing, so no view needs to know whether the game has juice.
  • Flashes: in 3D an overlay pass of one small unshaded shader, set only while the flash lasts and warmed up with every model; in a platformer the sheet's silhouette (made once, natively, with blit_rect_mask) drawn over the frame in the flash's colour; on a grid the avatar's colour.

Safe for everyone

  • WCAG 2.3.1 (three flashes or below): at most three flashes begin in any second, flashes that begin on the same tick counting once; the rest are held back and counted. Only an entity ever flashes: the vocabulary has no full-screen flash, red or any other colour.
  • The Game Accessibility Guidelines ask that shake and flashing can be turned off: the menu's Options offer Screen shake (on, reduced to half, off) and Flashes (on, off), only in a game whose Style has them. They are the person's, beside the volumes, and never reach the game.

Looks, not play

kit.vfx registers a view and no system, and its listeners only read. The platformer's presenting test runs the witness with the juice firing on every event and ends on the bare run's state hash; the samples' 600-tick and Playtest state hashes are the same with and without it.

Decided against

  • Fade. 3D models are opaque and the stipple is the only see-through; a fade would need a transparent pass sorted against the world (ADR-0007 §3). grow brings a thing in, a death has its burst, and a 2D-only fade would make the same Style look different by profile.
  • A screen flash or a hit-stop. A screen flash is the photosensitivity risk this kit keeps out; a hit-stop would stop the simulation's clock, which a view never touches.
  • The OS's reduced-motion setting. Godot reads none on the desktop; the person's own Options are the setting, on every platform alike.
  • A taller options page. Grown to seven rows, the glass covered enough of Tideline's night that its proof tour's options frame fell under L3's not-blank line (22 distinct colours of 24). The page keeps its five rows; Flashes and Back scroll into view under the list's ↓.

As built (2026-10-09)

The juice of each template (all but the visual novel) and of its samples and starters, in its own palette: a hit sparks, flashes and squashes the one hit and nudges the view; a ring of plate throws cold sparks; a kill throws debris and shakes; a downing shakes hard; a dodge kicks up dust; a take throws chips; a thing made grows in with embers; a shot flares, trails and splashes; a jump stretches and kicks dust, a landing squashes; a pickup sparkles; a hurt flashes and hops; a flag pops; a grid's step hops, a bump squashes, a solved room throws confetti.

Proof: the eight samples whose juice changed (Tideline, Nightcoast, Thornwick, Saltwell, Burrow Run, Acorn Rooftops, Lamplighter, Pier Pressure) keep every L1 and L2 state hash they had before (132 of 132), and L3 is green. Frames shot a tick or a few after each event, offscreen in Compatibility on llvmpipe, show the sparks, debris, embers, dust, trails, confetti, the flash (an overlay pass in 3D, the sheet's silhouette in 2D) and the squashes, and a shaken frame's world sits a few whole pixels off while its HUD stays put.

Order

  1. Restyle proven (§1): small, and it protects everything after it.
  2. kit.music (§2), web sample-mode test first (done: the probe above).
  3. The rig grammar (§3), then buildings (§4).
  4. Tiles and themes (§5) alongside the 2D templates.

Not in M6

Tier 2 generated assets (M7), voice, and a Forward+ look (ADR-0007 keeps Compatibility until the web can match it).

From docs/design/m6-presentation.md in the Gamewright repository.