← all games

rampage-fable-high — GAME.md

# EYEWALL — game manifest

Top-down spectacle action. You steer the eye of a tornado through a kingdom
that fights back. YAGE 0.10.4 linked from `../../../yage-local` (`main` at
`f49729e5`), with the dialogue, steering, feel, synth and virtual-controls
addons and the lab tool. Art and
music come from `../../../yage-assets` (Tiny Swords set by Pixel Frog for
units, buildings and ground; Kenney particles; library fonts and music).
Sound effects are synthesized with `@yagejs-addons/synth`.

Update this file after every implementation step.

## Scenes

| Scene | Responsibility | Enters from | Leaves to |
| --- | --- | --- | --- |
| `TitleScene` | Title with the live storm backdrop: continue or new campaign, almanac, settings | boot | `MapScene`, pushes `CodexScene` / `SettingsScene` |
| `MapScene` | Route with five nodes, ruins for razed ones, best scores, next node's intro, powers, kingdom pressure | title, debrief, pause | `PlayScene`, pushes `CodexScene` / `SettingsScene` |
| `PlayScene` | One node: scenery and the intro, then on `IntroFinishedEvent` the defenders, director, HUD, minimap and controls | map, pause (restart) | `DebriefScene` |
| `DebriefScene` | Outcome tally, raze/spare consequence, new almanac entries, three or four upgrade cards; writes the campaign and codex stores | play | `MapScene`, `PlayScene` (retry), pushes `CodexScene` |
| `PauseScene` | Overlay above play (`pauseBelow`, `transparentBelow`): resume, restart, almanac, settings, map | play | pops, pushes `CodexScene` / `SettingsScene` |
| `SettingsScene` | Overlay: sound levels, feel toggles, play options, erase campaign; applies and saves on every change | title, map, pause | pops |
| `CodexScene` | Overlay: the almanac. Five entry sections plus lifetime records; cards, detail panel, completion | title, map, pause, debrief | pops |

Every menu runs on `MenuNav` (`features/hud`): up/down or the stick moves
focus, confirm activates, left/right adjusts steppers and switches almanac
sections, cancel pops overlays. Mouse hover moves the same focus. Buttons
play `uiClick`, focus moves play `uiBlip`.

`main.ts` loads the campaign, settings and codex documents, binds
auto-persist for each, applies the audio settings, then pushes the title. It
also supplies the feedback plugin's capture context (`captureContext`), so a
comment left on the running game records the scene, node, storm category,
form and story setting it came from.
`?node=<id>` and `&form=<form>` jump straight into a node for testing;
`&story=off` holds the node intros back so a driven run reaches gameplay at
once.

A node opens with `StoryIntro`. `IntroFinishedEvent` is what starts the node:
`PlayScene.startNode` spawns the defenders, the kingdom's extra pressure, the
night lanterns, `NodeDirector` and its clock, the HUD, the minimap, the banner,
the touch controls and the pause handling. Until then the scene holds only its
scenery, so nothing has to be paused or exempted. A node ends by
`NodeEndedEvent` (from `NodeDirector`); `PlayFlow` waits a few seconds and
replaces the scene with the debrief.

## Directory strategy: feature-first

```
src/
  main.ts                 engine boot, campaign load, first scene
  boot/                   actions, layers, asset manifest, plugin set (shared with the lab)
  scenes/                 one file per scene
  features/
    storm/                Storm entity, motion, stats, orbit, hurl, surge strike, funnel, visuals
    debris/               pooled Debris, flight, pool, textures, Liftable, Damageable, layers
    world/                Ground bake, Building + tearing, Barrier (rock walls) + WallQuery, Prop + behaviors, NodeBuilder (terrain at load, defenders at node start)
    enemies/              Knight, ArcherTower, Arrow, Tether, Ward, Reinforcements, WindMage, Archmage, Arrival (fade in at node start), UnitDeath
    campaign/             node data, campaign store, upgrades, NodeDirector
    codex/                almanac entries, discovery store, CodexRecorder
    story/                cast, node exchanges, dialogue theme, portraits, Caster, Herald, StoryIntro
    settings/             settings store, audio application
    hud/                  PlayHud, Minimap, NodeBanner + EndBanner, AimReticle, TouchControls, MenuNav, Dimmer, UI theme
    fx/                   PlayFx (screen effects, clouds), StormFeel, TrailBuffer, NightLights, Forms, WindAudio, fx events
    audio/                synth sound table, music helper
lab/harness.ts            lab engine harness (same plugin set as the game)
tools/                    Playwright drivers: play.mjs (scripted keys + screenshots), drive.mjs (steer to points)
```

Scenario files sit next to the feature they exercise:
`storm/storm.scenario.ts` (handling), `debris/orbit.scenario.ts` (capture
and ring shape), `world/tearing.scenario.ts` (tier and reach gating).

## Entities and components

### Storm (`features/storm`)
- `Storm`: root `Transform` is the eye's ground point. No rigid body; the
  orbit and enemies read its position.
- `StormMotion`: input to velocity with momentum (`accelTau`, `decelTau`),
  a two-sine wander scaled by category, the surge dash with buffer and
  cooldown, node-bounds clamp. Integrates on the frame clock.
- `StormStats`: the storm is what it carries. Category (1..5) follows the
  mass in orbit (`MASS_FOR_CATEGORY`, reachable within each capacity):
  growth is immediate, shrinking waits 0.8 s under 80% of the floor and
  never drops below the node's start category. Energy drains with size
  (`2 + 0.4 × (category − 1)` per second), calm beams stack to a cap of 30,
  feeding refills. Tower ropes are counted into `tethers` each step. Emits
  `StormGrewEvent`, `StormShrankEvent`, `StormEnergyEvent`,
  `StormDissipatedEvent`.
- `OrbitField`: polls `[Liftable, Transform]` every fixed step, captures
  inside `pullRadius` when `tier <= category` and no ward is up, grinds the
  oldest piece past capacity, sums the orbit's mass into the stats, and
  gives pieces to `Hurl` through `take`. `captureAt` is the entry buildings use.
- `Hurl`: aims at a world point: the mouse (moved within 1.5 s), the right
  stick or the heading at 420 px, or auto-aim (casters anywhere in range,
  others inside an 80° cone ahead, highest `Damageable.priority` then
  nearest). Mouse and stick snap to a unit within 90 px when aim assist is
  on. A press cocks one piece, holding adds one every 0.18 s up to
  `volley`, release throws them; every piece flies from its own position
  to the point, so the volley converges. Roped pieces are cocked first.
- `SurgeStrike`: while surging, units within the core radius take damage.
- `WindAudio`: looped synth bed with pitch and volume from speed and size.
- Children: `DustSkirt` (particles), `BaseSwirl` (additive sprite),
  `Funnel` + `FunnelView` (cone traced as one path, texture-fill bands,
  gradient overlay, lean from velocity; drawn on the y-sorted world layer),
  `CloudDeck` (dark ellipses at the cone top on the sky layer),
  `AimReticle` (a ring at the aim point on the fx layer: gold with brackets
  when locked on a unit, pale for mouse or stick, dim for heading; fills
  clockwise with the charge and shows a dot per cocked piece; fades while
  the orbit is empty; off by setting), `glow` (night light source).
- `CodexRecorder` (added by the play scene): turns gameplay events into
  almanac records; see Codex below.

### Debris (`features/debris`)
- `Debris` (pooled, cap 80, prewarm 32): dynamic circle body, sprite on the
  world layer, blob shadow child on the shadows layer.
- `DebrisFlight`: `orbit` chases a slot on a squashed ring that widens as
  the piece climbs; a cocked piece gathers low on the aim side instead,
  tinted gold; `thrown` flies straight for about a second. Impacts emit
  `DebrisImpactEvent` and `DamageEvent` on `Damageable` targets (source
  `graze` from an orbiting piece, `debris` from a thrown one), scaled by
  `contactImpulse`. `tethered` marks a piece a tower rope holds (tinted
  ember). Height is a renderer transform modifier, never physics.
- `Liftable`: opt-in marker with tier, mass, energy, texture, label, `onLift`.
- `Damageable`: hit points, `priority` for auto-aim, `onHit`, `onDeath`,
  and an optional `filter(amount, source)` that sees every hit first (the
  mage ward returns what gets through).
- `debrisTextures.ts`: registers runtime textures once per play scene:
  unit frames for lifted knights and mages, and every building cut into a
  grid of texture regions (`piece:<kind>:<i>`).

### World (`features/world`)
- `Ground`: tiles drawn into an offscreen render target once, shown as one
  sprite; `BakedTexture` owns the target and key.
- `Building`: static box body, `SortGroupComponent`, `Tearing` (splits the
  sprite into piece children on first tear, releases pieces top-down at
  `TEAR_RATE` when a big-enough storm is in reach or on impact, swaps to the
  ruin sprite and removes the collider when empty), `Damageable` with huge
  hp whose `onHit` tears pieces.
- `Prop`: sprite or animated sheet plus `Liftable`; destroys itself on lift.
  Every prop has a `StormQuery` and one behavior from `PropBehaviors`:
  sheep get `Skittish` (run from the eye inside about 330 px plus half the
  pull radius, bleat while fleeing, clamped to the node), everything else
  gets `Wobble` (rocks away from the eye inside 1.7× the pull radius when
  the storm cannot take it yet, with a rustle now and then).
- `Barrier`: a rock wall from the cliff tileset (`WallDef`: cap `w`×`h`
  tiles plus one face row), tier-gated. Static collider for units and
  debris; `StormMotion` pushes the eye out of `rect` while
  `category < tier` (no physics body on the storm). At the tier it tears
  like a building; under it only thrown pieces chip it (`Damageable`
  filter drops grazes). Torn tiles become rock in the orbit; the last one
  emits `BarrierTornEvent` and the entity goes away.
- `WallQuery`: component that answers "is there a standing wall between
  these two points" (Liang-Barsky against every `Barrier.rect`). Mages
  use it: no beam through rock.
- `NodeBuilder.buildNode`: ground, walls, props, buildings, enemies from `NodeDef`.

### Enemies (`features/enemies`)
- `Knight`: runs to a standoff at the ring's edge, hacks the nearest
  orbiting piece every 1.2 s (`DebrisHackedEvent`, `hack` sound), staggers
  on hit, hp 5, liftable at category 3. Shouts once (an `AlertBubble` child
  and the `alert` sound) the first time the eye comes within 560 px.
- `Reinforcements` (on the `NodeDirector` entity): the levy. Knights arrive
  from the node edge farthest from the eye after `after` seconds, then
  every `every`, `count` plus the wave index each time (`NodeDef.
  reinforcements`, default 45 / 35 / 2). `WaveEvent`, a horn, a callout.
- `ArcherTower` (a `Building` of kind tower): archer child fires `Arrow`
  every 1.7 s inside 640 px. Arrows track their target for their whole
  flight (760 px/s, bending up to 5.5 rad/s). A hit orbiting piece is roped
  to the tower (`Tether`); the eye is the target when the orbit is empty or
  the tower already holds three ropes (−6 energy). Silenced when torn.
- `Tether`: one rope from the tower top to one piece, drawn on the air
  layer with sag that straightens and reddens toward its 560 px length.
  Each rope counts into `StormStats.tethers`, and `StormMotion` scales top
  speed by `max(0.4, 1 − 0.18 × tethers)`. Past the length it snaps and the
  piece is lost; throwing the piece or tearing the tower cuts it clean.
  `TetherEvent` (`roped`, `cut`, `snapped`) on the storm.
- `WindMage`: `SteeringAgent` (flee inside 250, arrive at a 340 ring,
  separation); channels a calm beam (6 energy/s, 60% while walking) inside
  250..440 with no wall between him and the eye; a hit interrupts the
  channel for 1.2 s; hp 8; liftable at category 4. Holds one shared
  `calmLoop` (`requestOnce`) while channeling.
- `Ward` (on the mage): a bubble of still air. While up, surge and lift do
  nothing and orbit grazes are ignored; three hurled hits break it (fire
  and lightning wear it and pass at half); it regrows after 6 quiet
  seconds. Two aimed full volleys kill a warded mage.
- `Archmage`: hp 72, keeps 300..520, blinks away under 230 (`blink` sound),
  calm field that trails him (9/s, 13/s and radius 300 when under half hp),
  summons 2-3 knights every 13 s. Not liftable. Death ends the cathedral node.
- `UnitDeath`: death poof then bones, self-destroying.
- `StormQuery`: component that finds the live storm through a query.

### Campaign (`features/campaign`)
- `nodes.ts`: five `NodeDef`s (hamlet, fields, market, castle, cathedral),
  each with `walls` (tier 2 pens and yards on the early nodes, closed
  tier-3 rings around the castle and the cathedral nave) and their mages
  inside them.
- `CampaignState.ts`: `createStore` root persisted through `@yagejs/save`.
- `upgrades.ts`: nine cards; forms are cards.
- `NodeDirector`: the node's objectives (`NodeDef.objectives`: building,
  archmage, mages, towers; same-titled ones fold into one line with a
  count), `ObjectiveEvent` per advance, fail state, tallies,
  `NodeEndedEvent` when every line is complete.

### Codex (`features/codex`)
- `entries.ts`: the 45 almanac entries in six sections (countryside 10,
  kingdom 9, powers 9, storm 5, route 5, register 6). Facts are read from `PROP_ART`,
  `BUILDING_ART`, `StormStats` and the enemy constants, so they stay true
  to the tuning. Unit portraits are sheet frames cropped to their opaque
  bounds, registered by `registerCodexTextures` for the scene's lifetime.
- `codex.ts`: the discovery store and its helpers. An entry is *sighted*
  when it appears in a node the player entered (silhouette and name shown),
  *known* once its count is above zero (art, lore, facts, record line).
  `takeNewDiscoveries()` hands the debrief the ids that became known.
- `CodexRecorder`: on the storm; sights the node's contents at entry and
  listens to capture, torn, killed, hacked, arrow, hurl, surge, grow,
  lightning, fire and node-ended events. Power cards are recorded by the
  debrief when taken.

### Story (`features/story`)
- `cast.ts`: the six speakers — Alderic Vane (the player) and whoever the
  kingdom sends to each node — with their nameplate colour, portrait key and
  which side of the box they speak from.
- `scripts.ts`: one exchange per node, built into a `defineScript` script
  whose lines carry their speaker's portrait and side as `meta`; the record
  line Vane dictates over each summon; `suppressExchanges()` for `?story=off`.
- `dialogue.ts`: the box theme (dark card, the `LIGHT` text set, the game's
  bitmap UI font) and the bundle, with an `InBoxAvatarPresenter` for the
  portrait column and the game's own action names bound to advance, hold to
  fast-forward and skip.
- `portraits.ts`: six unit frames cropped to their opaque box and tinted
  through a render target, registered under the `PORTRAIT` keys. A tint is a
  sprite property, not a texture property, and the avatar presenter takes only
  a texture key, so each one is baked.
- `Caster`: Vane, a child of the storm, on the warrior's frames under a violet
  recolor and a glow — the Tiny Swords set has no robed figure, and the
  warrior is its only human with a readable body and an overhead pose. Two
  sprites: one on `world` inside the storm's sort group, added before the
  funnel so the forming cone closes over him, and one on `air` that draws in
  front of the cone's near wall once he is riding, dark and half-transparent
  with a `wave` shimmer.
- `Herald`: the kingdom's speaker, standing across from him for the length of
  the exchange, placed clear of buildings, towers and trees. Runs off when the
  funnel forms; the Archmage fades instead.
- `StoryIntro`: plays the exchange, then runs the summon — staff up, motes
  gathering, `SummonReveal` growing the funnel, thunder and a shake at two
  thirds — and emits `IntroFinishedEvent`. It stops nothing, because the node
  has not started: the scene holds only its scenery until that event. The
  summon runs 3.6 s after an exchange and half that without one.

### Settings (`features/settings`)
- `settings.ts`: master, music, effects and wind levels (fractions of
  `CHANNEL_BASE`), screen shake scale, hitstop, flashes, aim assist,
  reticle, touch controls. `applyAudioSettings` pushes the levels into the
  audio channels. `StormFeel` reads shake, hitstop and flashes per cue;
  `Hurl` reads aim assist; `AimReticle` and `TouchControls` read theirs at
  spawn.

### Effects (`features/fx`)
- `PlayFx`: an implosion on the ground layer following the eye, a vignette at
  screen scope, the form's color grade; `CloudShadows` on the sky layer (camera
  translate ratio 1.18). Nothing filters the `fx` layer: it is a transparent
  overlay of additive sprites, and a filter there flattens it — the ground
  under the filter's rectangle changes and the additive light is lost.
- `StormFeel`: `Feel` cues for surge, hurl, impact, kill (hitstop), grow,
  callouts, hacked, arrow, dissipate; plays the matching synth sounds.
- `TrailBuffer`: two offscreen buffers feeding into each other, shifted by
  the camera delta, drawing streaks behind every flying piece.
- `NightLights`: `fallNight` sets ambient 0.42 blue and a cool light on the
  storm at scene load; `hangLanterns` puts a warm light on every building when
  the node starts, which is when the towers exist to carry one.
- `Forms`: Thunderhead spawns `LightningStrike` at thrown impacts (light,
  damage, tears pieces, thunder); Ashstorm tints debris and adds `Burning`
  to hit buildings (eats a piece every 1.4 s, spreads at 4.5 s).

### HUD (`features/hud`)
- `PlayHud`: energy bar with the "feed the storm" warning, category pips
  with the growth bar to the next category (and the "heavier pieces" hint
  when the ring is full), orbit count (gold at capacity), surge charge
  bar, one line per objective with `[ ]`/`[x]` and a count, razed and
  taken tallies.
- `Minimap`: bottom-right on the `minimap` screen layer. The ground's own
  baked render target scaled down, walls and buildings redrawn when one
  falls, and live marks: eye, camera window, knights, mages, towers,
  Archmage, and a pulsing gold mark on every open objective.
- `NodeBanner`: title and intro for 4.5 s. `EndBanner`: the node's last
  word ("... IS RUBBLE", "... FALLS", "THE STORM DISSIPATES") while the
  debrief loads; `nodeWon` plays on a win.
- `MenuNav`: keyboard, gamepad and hover focus over a menu's buttons, with
  the UI sounds. `Dimmer`: the fading shade under overlay menus.
- `TouchControls`: two floating sticks and two buttons; auto, on or off by setting.

## Communication

- Entity events for gameplay facts: `DebrisCapturedEvent`,
  `DebrisImpactEvent`, `DamageEvent`, `BuildingTornEvent`,
  `PieceTornEvent`, `EnemyKilledEvent`, `DebrisHackedEvent`,
  `ArrowHitEvent`, `StormGrewEvent`, `StormEnergyEvent`,
  `StormShrankEvent`, `StormDissipatedEvent`, `SurgeEvent`, `HurlEvent`,
  `NodeEndedEvent`, `LightningEvent`, `FireEvent`, `TetherEvent`,
  `WaveEvent`, `BarrierTornEvent`, `IntroFinishedEvent`. HUD, feel, audio, director, forms and the codex recorder
  listen on the scene.
- Direct references from the composition root: `PlayScene` passes the
  storm and camera to the HUD, feel, effects and trail; the debris pool to
  the storm.
- Queries instead of references where the producer should not know the
  consumer: `OrbitField` polls `Liftable`, `Hurl` polls `Damageable`,
  enemies and buildings find the storm through `StormQuery` or a
  `[StormStats, OrbitField, Transform]` query.
- Global bus: none beyond engine events.

## Input actions

| Action | Keyboard | Gamepad | Touch |
| --- | --- | --- | --- |
| up / down / left / right | W A S D, arrows | left stick, d-pad | left stick |
| hurl | Shift, J, mouse left | RT, RB | HURL button |
| surge | Space, K, mouse right | A, LT, LB | SURGE button |
| pause | Escape, P | Start | — |
| confirm / cancel | Enter, Space / Escape | A / B | tap |

During a node intro the same names drive the dialogue box: `confirm` advances
a line, `hurl` held fast-forwards the typewriter, `cancel` skips the exchange
and then the summon. Pause is not spawned until the intro ends, so Escape
means skip while it is running.

Menus reuse up / down / left / right for focus and steppers. Escape is
both pause and cancel, so the pause menu handles it in one place
(`PauseInput`) and the overlays through `MenuNav.cancel`.

## Layers (PlayScene)

| Name | Order | Notes |
| --- | --- | --- |
| ground | -20 | baked tiles sprite |
| shadows | -10 | blob shadows |
| world | 0 | y-sorted: props, buildings, units, debris, funnel, the caster on his feet |
| air | 10 | arrows, dust, the caster once he is riding |
| funnel | 20 | trail buffer sprite (additive) |
| fx | 30 | additive particles, beams, lightning, callouts; bloom |
| sky | 40 | cloud deck, cloud shadows (parallax 1.18) |
| minimap | 950 | the minimap (screen space; not a world layer, so the form grade skips it) |
| lighting | 900 | lighting overlay (plugin, screen) |
| ui | 1000 | HUD (plugin, screen) |
| dlg-frame / dlg-avatar / dlg-text | 1100 / 1105 / 1110 | the intro dialogue box (addon, screen) |

## UI theme

`features/hud/theme.ts` carries two text palettes: `TEXT` is ink for the
parchment panel (measured 7.4 : 1 body, 5.2 : 1 small against the
texture's mean colour), `LIGHT` is the pale set for the dark cards.
Accent inks (`INK`) sit at 4.3 to 4.7 : 1 on the panel; the light accents
(`COLOR`) are for dark cards only. Button labels are white with a dark
outline; disabled buttons take ink.

## Assets

`src/boot/assets.ts` lists every handle. Credits accumulate in
`public/assets/CREDITS.md` from the library CLI. The renderer runs with
`pixelArtPreset: true`: nearest sampling, whole-pixel sprite positions and a
pixelated canvas, without which the 192 px unit sheets sample across their
frame borders.

## Save payload

Three documents (`@yagejs/save`, localStorage namespace `eyewall`), each
auto-persisted on change:

- `campaign`: `cleared`, `razed`, `spared` (sets of node ids), `upgrades`
  (list of ids), `form` (`none | thunderhead | ashstorm`), `tribute`
  (counter), `bestScores` (map), `heard` (node ids whose intro exchange
  played), `current` (node id), `started` (bool).
  Erased by "New campaign" and by the settings' "Erase campaign".
- `settings`: `master`, `music`, `sfx`, `wind`, `shake` (0..1), `hitStop`,
  `flashes`, `aimAssist`, `reticle` (bool), `touch` (`auto | on | off`),
  `story` (`full | once | off`).
- `codex`: `sighted` (set of entry ids), `counts` (entry id → count),
  `stats` (stat id → total), `bestTime` (node id → seconds). Lifetime
  records; never erased by the game.

Runtime storm state is never saved; a node restarts from its definition.