← all games
metroidvania-action-platformer-gpt-5-unknown — GAME.md
# Game manifest
Status: approved architecture. Milestone 3 is implemented, and `npm run build`, `npm run test:unit`, `npm run test:browser`, and `npm run lab:test` all pass against it. The production movement, combat, grapple, catch, pressure, energy, and manipulation components are composed through the fixed-step scheduler, and the movement, combat, and manipulation gyms expose their rules. Update after every implementation step that changes architecture or ownership.
## Directory strategy
Use feature-first organization:
```text
src/
main.ts # production launcher
scenes/
createGameRuntime.ts # engine and plugin composition used by both entries
features/
player/
PlayerContacts.ts
PlayerMotor.ts
PlayerStance.ts
playerAssets.ts
playerTuning.ts
combat/
grapple/
GrappleTarget.ts
TargetRegistry.ts
GrappleController.ts
CaughtController.ts
enemies/
bosses/
world/
GameplayFixedScheduler.ts
MovingPlatform.ts
terrainAssets.ts
floodworksAssets.ts
spireAssets.ts
progression/
economy/
persistence/
map/
menu/
presentation/
GameCamera.ts
CameraDirector.ts
content/
sections/
abilities/
enemies/
bosses/
items/
quests/
shops/
dialogue/
shared/
lab/
harness.ts # the engine and plugins every scenario runs against
scenarios/ # development-only, never imported by src/
gym/
player/
world/
combat/
persistence/
menu/
presentation/
input/
tests/
fixtures/ # test-owned IDs and data fixtures
unit/ # Vitest, node environment
browser/ # Playwright, production entry only
evidence/<milestone>/ # run logs; screenshots are not kept
```
`scenes/` contains composition roots. Each feature may contain its headless model, YAGE components, entities, and presentation adapters. `content/` is declarative data. `shared/` is limited to stable IDs, math helpers, and types owned by no feature.
`src/shared/collisionLayers.ts` holds the ten collision layers `TDD.md` lists,
one exported bitmask each: `worldSolid`, `oneWayPlatform`, `movingPlatform`,
`playerBody`, `enemyBody`, `playerHitbox`, `enemyHitbox`, `projectile`,
`trigger`, and `grappleAnchor`. Every collider names its membership and its
mask from that module. Bitmasks come from declaration order and are never
persisted, so the names are the contract and the numbers are not.
`src/features/world/` is split in two. `sections/` holds the section data
layer, which is headless and has no engine dependency beyond the free functions
in `@yagejs/tilemap`:
| File | One responsibility |
| --- | --- |
| `sections/sectionSchema.ts` | The Tiled convention as data: accepted layers, classes, geometry, and properties |
| `sections/SectionMapParser.ts` | Raw-JSON preflight and typed parse of one map into a `SectionDef` |
| `sections/sectionDef.ts` | `SectionDef` and the authored record types it holds |
| `sections/worldGraph.ts` | Exit-derived connections, transitions, map edges, and atlas-overlap checks |
| `sections/diagnostics.ts` | Diagnostic codes, the diagnostic record, and its one-line format |
The folder root holds the world's runtime components, which do use the engine:
| File | One responsibility |
| --- | --- |
| `MovingPlatform.ts` | Follow one straight path and publish the displacement of the next fixed step |
| `GameplayFixedScheduler.ts` | Invoke the fixed-step stages in one order, and apply one motion command per body |
Neither part has a barrel file, so a node test importing the parser never
reaches the engine.
`src/features/player/` holds the body and what moves it:
| File | One responsibility |
| --- | --- |
| `PlayerStance.ts` | Create and replace the body collider, and own its callback subscriptions |
| `PlayerContacts.ts` | Classify ground, walls, ceiling, supports, and correction candidates |
| `PlayerMotor.ts` | Own locomotion state, movement resources, and the motion committed for the next step |
| `playerTuning.ts` | Every number the body and its moves are measured in |
| `playerAssets.ts` | The installed player sheets and the states they draw |
`src/features/presentation/` holds what decides where the camera looks and how
a body is drawn:
| File | One responsibility |
| --- | --- |
| `GameCamera.ts` | Assemble the camera: the director in front of the engine's own camera components |
| `CameraDirector.ts` | Own look-ahead, the vertical landing anchor, section bounds, camera hints, and the arrival snap |
The current starter smoke scene remains in `src/scenes/GameScene.ts`, with its
entities under the scaffold's `src/entities/` and `src/components/` folders.
Those files prove the production launcher still runs; production feature work
does not extend their layer-first structure.
## Implemented entry boundary
`src/scenes/createGameRuntime.ts` owns the engine and its plugin list, split so
both callers build them the same way: `createGameEngine()` and
`createGamePlugins({ container })`, with `createGameRuntime` composing the two
for the game itself. `src/main.ts` calls it and pushes the starter `GameScene`.
`lab/harness.ts` hands the same two to the scenario lab, so a scenario runs
against the game's own plugin set rather than a second runtime.
Vite's production input is `index.html` only, and nothing under `scenarios/`
is reachable from it.
### Scenarios
Development-only situations live in `scenarios/`, one folder per feature they
exercise, and are run by `@yagejs-tools/lab`:
```bash
npm run lab # the browser panel, with live controls
npm run lab:test # every scenario headless, non-zero on a failure
```
A scenario file is `*.scenario.ts` and exports one or more `defineScenario`
results. Each declares the controls it offers, creates a `Scene` subclass through
the `scene` field, and may carry a `drive` that plays the situation over an
exact number of frames and asserts on it. The panel's Run button and
`yage-lab test` execute the same drive, so what a person watches and what the
gate checks are one thing.
Each situation is a `Scene` subclass. Its `name`, `layers`, `preload`, and
`onEnter()` define the scene boundary, and `onEnter()` lists the entities in
spawn order. A thing with more than one component beyond `Transform` is a
named `Entity` subclass with a `setup(params)` method. The scene spawns that
class. Engine-provided entities, one-component markers or backdrops, and
children composed inside an entity are the only exceptions. Component add
order inside an entity follows update order, so the class keeps the order that
its behavior requires.
The panel has two sections. Scenarios whose `title` starts with
`mechanical-tests/` assert an invariant and report pass or fail. The remaining
scenarios stay at their file paths because their check packets ask a person to
judge the room, player art, or camera presentation. The panel rule is the
question in `WORK-STATUS.md`, not the scenario's folder or export name.
Every scenario spawns `scenarios/shared/ScenarioReadout.ts`. The readout is a
screen-space entity with three parts: the scenario's claim, the keys bound by
its input component, and live values read from the scenario state.
`world/movingSupport.scenario.ts` uses one parameterized `MovingSupportScene`
for its four exports because the exports vary the same layout and speed
controls over one population. `presentation/camera.scenario.ts` uses one
parameterized `CameraScene` for the seven camera exports for the same reason.
The other scenario files use one scene class per situation when their
populations or control wiring differ.
| Folder | What it exercises |
| --- | --- |
| `scenarios/gym/` | The movement, combat, and manipulation lanes a person plays, the movement rules asserted one at a time, the combat rules — guard, parry, receipts per hurtbox, energy transactions, the admission rows a combat lane reaches, and gamepad parity — and the grapple's attachment phases, techniques, and throw modes |
| `scenarios/player/` | The stance contract and the installed player-art state gallery |
| `scenarios/world/` | Rider transfer on moving platforms: carry, drawn position, detaching, the crush; the staged retirement drain of a boss tree; the Transit Ward city block; the Floodworks tunnel room; the Broadcast Spire cavern |
| `scenarios/combat/` | The catchable body composition and an outgoing target handle; the committed velocity an impact is judged by and its episode ledger; one projectile's receipts under three render schedules — all three on the production combat components |
| `scenarios/persistence/` | Two rapid autosaves into one slot, unordered and queued |
| `scenarios/menu/` | One focus screen driven by keyboard, D-pad, and left stick |
| `scenarios/presentation/` | Camera focus: look-ahead, the vertical anchor, bounds, shake, hints, drawn distance |
| `scenarios/world/` (scheduler) | The fixed-step stage order, and one command per body |
| `scenarios/input/` | Pad polling against a synthetic button |
Stand-ins live beside the scenarios that need them, named for what they
replace:
| Stand-in | Replaced by |
| --- | --- |
| `world/ParallaxDriftStandIn` | The area system that owns backgrounds |
| `world/RetirementQueueStandIn` | `SectionRetirementController` |
| `world/SectionRuntimeStandIn` | `SectionRuntime` |
| `persistence/SaveQueueStandIn` | `SaveCoordinator` |
| `menu/MenuFocusStandIn`, `menu/MenuDirectionalInputStandIn` | `MenuFocusController` and `DirectionalInput` in menu mode |
| `presentation/CameraHintRegionStandIn` | `SectionCameraController` |
Two scenario modules are not stand-ins and replace nothing.
`shared/playerRig.ts` composes the production player the same way for every
scenario that needs a body, and `world/SupportTracker.ts` measures the drawn
distance between a rider and its platform, which is a measurement rather than a
rule.
A stand-in carries the counters its drive asserts on, and no production code
imports any of them. The catch-body, impact, and projectile scenarios drive
`CombatantRoot`, `HurtboxEntity`, `PressureState`, `ProjectileEntity`, and real
`Abilities` timelines. The catch-body scenario reuses the world's retirement
queue and section runtime rather than building a second drain.
Every scenario binds keys, so a person can drive the situation while the panel
plays rather than only watching a canned run. They use the game's own `left`,
`right`, and `jump` actions plus a few scenario-local ones, and each key calls
the same method the drive calls — so watching and asserting exercise one path.
`scenarios/world/RoomKeys.ts` is the room scenarios' shared handler. A key
holds its own last state and writes only when that changes. Comparing against
the state being driven instead lets a keyboard resting at zero cancel what a
drive just asked for, one frame later. Each scenario's `describe` names its
keys.
Three more rules the scenarios depend on:
- **A drive holds the `Scene`,** so it reads components directly and a
component needs no `serialize()` to be visible to a test.
- **A control change rebuilds the scene,** so `setup` has to run again from
nothing. Values a scenario varies are constructor parameters.
- **A drive owns the clock.** Every call that advances a frame is awaited, and
the lab clock's fixed 1/60s delta can be replaced for a run through
`__yage__.inspector.time.setDelta` — which the two drawn-distance scenarios
do, because a measurement at exactly one step per frame cannot see a reader
sampling the wrong pose.
## Verification
| Command | Covers |
| --- | --- |
| `npm run build` | Typecheck of `src`, `scenarios`, `tests`, and the config files, then the production bundle |
| `npm run test:unit` | Vitest in a node environment: the section parser and world graph against fixture maps, plus a production build whose output is scanned for scenario modules |
| `npm run test:browser` | Playwright against the development server: the production entry boots its scene with no console, page, or engine callback error |
| `npm run lab:test` | Every scenario headless, each in its own page |
| `npm test` | All three in order |
The isolation scan enumerates every module under `scenarios/` and asserts none
appears in the built output, so a scenario that introduces a new name is
covered without anyone maintaining a list.
## Production scene graph
| Scene | Lifecycle responsibility | Outgoing edges |
| --- | --- | --- |
| `BootstrapScene` | Register plugins; load settings, bindings, fonts, and asset manifest | `TitleScene`; fatal asset error |
| `TitleScene` | New game, four-slot listing and load, slot errors, full gamepad focus | `WorldScene`; settings modal; fatal/recoverable load error |
| `WorldScene` | Persistent player, camera, HUD, run services, and one active section | `PauseScene`; `TransitionScene`; `DeathScene`; `CompletionScene`; safe return to `TitleScene` |
| `PauseScene` | State, Equip, Journal, Map, Settings; full pause below | pop to `WorldScene`; terminal save/load/travel commands; title confirmation |
| `TransitionScene` | Lock input while `WorldDirector` drains and builds a section | pop to `WorldScene`; rollback; recovery to `TitleScene` |
| `DeathScene` | Present death, apply one currency penalty, rebuild checkpoint | pop to `WorldScene`; recovery to `TitleScene` |
| `CompletionScene` | Network-reclamation result, final journal, slot actions | `TitleScene`; continue completed run if retained |
A terminal interaction pushes `PauseScene` with a short-lived context that enables shop, manual save/load, and fast travel. Opening the ordinary pause menu does not grant those commands.
The scenario lab is the second way this game runs. It serves its own page and
mounts one scenario at a time; the production `index.html` graph cannot reach
any of it, and `src/` imports nothing from `scenarios/`.
## Persistent service owners
| Owner | One responsibility |
| --- | --- |
| `ProfileDocument` | Settings and exported keyboard/gamepad bindings, separate from run slots |
| `RunAggregate` | Canonical progression, inventory, quest, economy, map, and checkpoint models |
| `LoadCoordinator` | Decode, migrate, validate, swap, rebuild, rollback, or recover one run |
| `SaveCoordinator` | Order manual and same-slot autosave writes |
| `WorldCatalog` | Static section definitions and validated exit-derived graph |
| `AssetManifest` | Semantic asset ID to base-relative URL and metadata |
| `AudioMix` | Master, music, ambience, sfx, and UI channel state |
These are assembled by a scene composition root. Game state is not registered as an engine `ServiceKey`.
### Implemented player asset manifest
`src/features/player/playerAssets.ts` is the first implementation of the
`AssetManifest` owner. `PLAYER_SHEETS` maps the nine Luis Zuno catalog IDs to
base-relative PNG and companion JSON URLs, frame metadata, and preload handles.
`PLAYER_STATE_ASSETS` maps player states to those sheets and marks derived poses,
approved base poses, and asset gaps.
`scenarios/player/art.scenario.ts` reaches the manifest through
`PLAYER_ASSET_PRELOAD` and uses each sheet's atlas source. The scenario draws
the installed source beside the active state, sets the facing through the
player Transform, and draws the 12×44 standing or 30-tall slide collision guide
at the same foot position. A `state` select control and bound keys — arrows or
A/D cycle the state, F flips the facing — call the same `showState` the drive
uses, so the panel shows a state without running the drive. The scenario
imports the manifest from `src/`; nothing in `src/` imports from `scenarios/`,
and the lab reaches the scenario through `lab/harness.ts`.
### Implemented terrain asset manifest
`src/features/world/terrainAssets.ts` is the terrain, city-background, and
prop part of the `AssetManifest` owner. `TERRAIN_TILESET` records the external
tileset's URL and image grid. `CITY_TILES` records the sheet's vocabulary: the
walkable cap run, the dark interior fill, the stretchable pillars, the floor
band, and the ladder. The sheet is a kit rather than an autotile set, so tile
placement is the author's judgment in Tiled and no rule table constrains it.
What is checked is that collision agrees with the tiles:
`validateRoomGeometry` reports rectangles off the tile grid, rectangles left
behind after their tiles were deleted, and walkable caps with no collision. It
takes the walkable tile ids as an argument, because each area's sheet marks
its walking surface with different tiles; `CITY_CAP_IDS` is the Transit Ward
set. `tests/unit/room-geometry.test.ts` runs it on the authored map and the
scenario drive runs it on the loaded one. `TRANSIT_CITY_ROOM` owns the map
preload and its layer names, `CITY_PARALLAX` the three background layers, and
`CITY_PROPS` the signage sprites and their placements.
A room is built from buildings rather than from ground. The map carries three
tile layers — `facade` behind, `decor` for fittings, `structure` for every
collision-backed surface — each spawned as its own `TilemapComponent` on its
own render layer. Collision comes from the map's `collision` object layer, not
from tiles. The `decor` layer is authored and holds no tiles yet; it is kept as
the home for fittings. Treatment
tints the parallax sprites and assigns `TilemapComponent.tint` on each tile
layer; the player and the signage stay untinted.
### Implemented Floodworks asset manifest
`src/features/world/floodworksAssets.ts` is the Floodworks part of the
`AssetManifest` owner, and its room is put together the opposite way round
from the Transit Ward one. The `sewers` sheet draws only walkway: `SEWER_BLOCKS`
holds its whole vocabulary as three fixed 3×3 assemblies — an opening run, a
repeating middle, and an end that breaks off. `FLOODWORKS_WALLS` carries the
three `bg-sewers-*` layers that supply the tunnel itself, `CANAL_BANDS` the
three tiling water strips behind the walkway, and `FRONT_WATER` the four-frame
channel drawn in front of the player.
Three consequences follow from a sheet that draws one thing:
- **One tile layer.** There is nothing to put behind or in front of the
walkway on the tile grid, so `floodworks-room.json` carries `structure` and
the `collision` object layer and no more.
- **Units are validated, not adjacency.** `validateWalkwayBlocks` reports any
tile that is not part of a whole 3×3 assembly. A fixed-assembly sheet wants
that check where the freeform Transit Ward kit wants none.
- **The walking line is the unit's second row.** A unit is drawn in
perspective and sits behind the player: the 5px above that row's top edge is
the walkway's far lip, which the body passes in front of, and the two rows
below it are solid walkway. `FLOODWORKS_CAP_IDS` is therefore the second row,
and collision is the bottom two rows, which matches the art on both edges.
Treatment recolours rather than tints. The walls are pure olive with no blue
channel, so a multiply can only darken them; `colorize` from `@yagejs/effects`
maps luminance onto a colour instead and attaches through
`VisualComponent.fx`. Strength falls towards the camera, so the far vista goes
cold while the near wall keeps its pipes and lamps readable. The water carries
no treatment at all — it is the area's own light source, the way the Transit
Ward signage is — and neither does the player.
### Implemented Broadcast Spire asset manifest
`src/features/world/spireAssets.ts` is the Broadcast Spire part of the
`AssetManifest` owner, and its room is a cave. The `warped-caves` sheet draws
surfaces rather than masses, and it draws them in pairs: `surfaceBody(edge)` is
`edge + columns`, the tile one sheet row below, and every lit edge tile expects
that partner underneath it. `FLOOR_EDGES` and `ROOF_EDGES` name the edges the
room uses. A floor is an edge over its body, with the lit stone filling the
edge tile's bottom 3 to 5px; a roof is the same pair upside down, its edge the
rock's solid underside and its body the teeth hanging into the passage.
`SPIRE_CAP_IDS` is the floor bodies, which is the row the walking line runs
along. `SPIRE_SOLID_IDS` is every form of rock, because in a cave the roof and
the end walls carry collision too — `validateRoomGeometry` takes it as a fifth
argument so a rectangle backed by rock rather than by a walkable top still
passes. `validateRockSurfaces` holds every edge tile to carrying its body,
which is the one rule the sheet imposes and the one whose failure shows: an
edge with nothing under it draws a lit stone line hanging in mid-air.
Being a cave decides the rest of the composition:
- **Two tilesets in one map.** `warped-caves` draws the surfaces and everything
embedded in the rock; `warped-caves-walls` is the plain fill the mass is made
of, with crevices and lit stone faces in it. Tiled numbers a map's tiles
across every tileset it references, so `SPIRE_WALL_TILESET.firstGid` is what
makes a wall tile's id in the map differ from its id in its own sheet.
- **Two tile layers.** `wall` is the mass behind and `structure` carries the
surfaces in front. Passages are empty on both, which is what lets the cavern
show through them.
- **The room closes in rock.** Both ends are solid columns rather than an
invisible barrier, so nothing in the map uses the `bound` class.
- **The cave is cut as horizontal reaches.** A few long flat runs at a handful
of heights, with the rock cutting square between them, which is how the
sheet's own example map is composed. Where the rock over a reach is thick
enough, a gallery is carved through it, so the room reads as more than one
passage.
- **The roof is the clearance.** Where it comes down to three tiles over the
floor the standing body passes; where it comes down to two, only the slide
does.
`SPIRE_CAVERN` holds the two `bg-caves-*` layers seen through the openings: a
lit magenta cavern behind rock with holes in it. Both are 176 tall and are
placed to span exactly the room's open band.
Treatment is contrast rather than hue, because the rock is already magenta and
a wash pushing it further flattens it. The tilemaps take a Pixi
`ColorMatrixFilter` with contrast and saturation raised; the cavern layers take
multiply tints that darken the near rock hardest, so the lit cavern stays the
brightest thing behind the passage. A multiply works here where the Floodworks
walls needed a recolour, because this art has channels for a tint to scale.
`RELAY_GATE`, the lit portal at the cave's far end, is never treated — it is
the area's own light source, as the Transit Ward signage and the Floodworks
water are.
## Entities and components
### World
| Entity/component | One responsibility |
| --- | --- |
| `WorldDirectorEntity` | Own the current section generation and transition transaction |
| `WorldDirector` | Serialize exit, fast-travel, death, and load world mutations; retire post-order, build, settle, or roll back |
| `SectionMapParser` | Validate Tiled conventions and derive immutable section/world records |
| `SectionRuntime` | Hold only current section ID, generation, lifecycle state, and specialized controller references |
| `SectionRetirementController` | Merge owner-submitted trees and disposers into one safe post-order drain |
| `SectionGeometryController` | Own one section's tilemap render and extracted static collision |
| `SectionSpawnRouter` | Validate a spawn, resolve its archetype, transfer its root to the feature owner, and retain no registry |
| `SectionExitController` | Own arrivals, exit sensors, paired-exit requests, and transition locks |
| `SectionEncounterController` | Own encounter zones, waves, and area-visit defeat state |
| `SectionSurveyController` | Own surveyed-cell triggers and discovery events |
| `SectionCameraController` | Apply parsed bounds and camera hint regions |
| `SectionAudioController` | Apply parsed music and ambience regions |
| `ExitEntity` / `ExitTrigger` | Report one paired exit crossing |
| `CheckpointEntity` / `CheckpointController` | Activate one checkpoint idempotently |
| `CollectibleEntity` / `CollectibleController` | Grant one stable reward once |
| `ShortcutEntity` / `ShortcutController` | Open one persistent local shortcut |
| `MovingPlatformEntity` / `MovingPlatform` | Follow one authored path and publish support delta |
| `OneWaySupport` | Optional sensor-based support policy; never required until its proof passes |
| `GameplayFixedScheduler` | Invoke focused fixed-step stages in a tested order without owning rules or feature state |
| `MotionCommitter` | Apply one motion command per body per fixed step and refuse the rest |
#### Implemented: `GameplayFixedScheduler` and `MotionCommitter`
`src/features/world/GameplayFixedScheduler.ts` holds both. The scheduler runs
with ordinary component fixed updates at priority 1000, after `PhysicsSystem`
at priority 0, so a command committed there is consumed by the physics step
that follows.
It is an order-only boundary: it owns no feature state, runs no rule, looks up
no entity, and holds no timer. Participants register as callbacks in one of six
slots, and the slots run in the order `TDD.md` fixes — `classify`, `support`,
`motion`, `grapple`, `combat`, `enemy` — followed by the commit. Support is its
own slot ahead of motion because the rider transfer only holds if every
platform has committed the displacement of the coming step before any rider
reads it.
`MotionCommitter` applies one command per body per step. Arbitration is
first-come: a second command for the same body inside one step is refused and
counted, because two rules writing one body is the failure the boundary exists
to make visible.
#### Implemented: `MovingPlatform` and the rider transfer
`src/features/world/MovingPlatform.ts` owns one platform's path. Its body is
kinematic, which in Rapier means position-based: the physics step reads the
Transform and moves the body to it. `advance(dt)` therefore commits the pose
the *next* fixed step will realize and publishes that step's displacement as
`supportDelta`.
`advance` is not a `fixedUpdate`. The stage that owns support motion calls it,
because the transfer only holds if every platform commits before any rider
reads. The order per fixed step is: classify what the body touches, commit every
platform, then let the motor read the support it stands on and add that
platform's velocity to its own.
`GameplayFixedScheduler` is what calls it, in the support stage.
A rider receives the displacement as velocity: over one fixed step a velocity
of `supportDelta / dt` covers exactly the committed displacement, and gravity
still accumulates during the step, which keeps the rider pressed onto the
platform. `MovingPlatform.supportVelocity` publishes that velocity, and
`PlayerContacts` and `PlayerMotor` are the rider. Both halves of the rule in
`TDD.md` hold: a contact makes a rider only when its normal points from the
rider down toward the platform, and only while the rider is not leaving that
platform faster than the platform itself moves.
A platform's `Transform` is its movement input, written in the fixed-step
stage, and the engine captures that write before it blends the pose the
platform is drawn at. Platform and rider are therefore drawn from one clock,
and both draw from their own entity. `FRICTION.md` records what this cost
before `@yagejs/physics` 0.10.1, and the measurement that proves it now.
### Player
| Entity/component | One responsibility |
| --- | --- |
| `PlayerEntity` | Composition root and stable combatant receipt identity |
| `DirectionalInput` | Produce move, intent, aim, and active-device state under one input mode |
| `PlayerContacts` | Classify ground, walls, ceiling, supports, and correction candidates |
| `PlayerMotor` | Own locomotion state, movement resources, and committed next-step motion |
| `PlayerStance` | Create/replace body collider and own its callback subscriptions |
| `PlayerActionInput` | Resolve interact/melee priority and request admitted abilities |
| `AbilityAccessPolicy` | Derive allowed action IDs from canonical progression facts |
| `Abilities` | YAGE addon component for timelines, lanes, cooldowns, and transient execution |
| `Energy` | Reserve, commit, refund, gain, and clamp precision energy |
| `GrappleTarget` | Store one target's stable identity, class, range, resistance, and attachment offset |
| `TargetRegistry` | Filter and rank current grapple targets for preview and fire |
| `GrappleController` | Own one player's attachment record, reel, discharge, and detach paths |
| `CaughtController` | Own one caught root's socket motion, timer, collision mode, and restoration |
| `PlayerAnimation` | Project player state to sprite pose and timing |
| `PlayerPresentation` | Project movement/combat events to trails, particles, light, audio, and shake |
#### Implemented: `PlayerStance`
`src/features/player/PlayerStance.ts` is the only code that makes, replaces, or
subscribes to the player's body collider. It is added after `Transform` and
`RigidBodyComponent`, and it adds the `ColliderComponent` itself during
`onAdd`.
Both stance shapes are boxes anchored at the feet: the Transform position is
the foot point and the collider carries an `offset` of half its height upward.
A stance change therefore never moves the feet, and an arrival point authored
in Tiled reads as the place the player's feet land.
The two stances are `standing` and `low`, named for the shape rather than for a
move: the slide, the crouch, and a dash out of either all occupy the low box.
They share one width and differ only in height. The clearance test measures the
headroom above the low box, so a standing box wider than the low box could
expand into a wall nothing had checked.
Both boxes carry `body.borderRadius` from `playerTuning.ts` on their corners.
The outer footprint is unchanged, so every clearance rule holds as written; the
rounding keeps a square corner from catching on the vertex where two terrain
segments meet. Two bounds hold the number: the engine refuses a radius at or
above half the shorter side, and the flat part of each face shrinks to
`width - 2 * borderRadius`, so a lip narrower than the radius meets the rounded
corner and the body slides off it. `mechanical-tests/gym/movement`'s
`ledgeOverhang` situation stands the body on a lip supporting a quarter of its
width and is the upper bound. Every probe in `PlayerContacts` that stands in
for the body — the two shape casts and the fit query — takes the same radius,
because casts and overlap queries use the rounded geometry.
`setStance(next)` returns the stance in effect afterwards. A request for
`standing` first runs `hasStandingClearance()`, an overlap query over the
volume the standing box would newly occupy above the slide box. Two corrections
apply to that query. It is inset one pixel on each side, without which standing
is refused beside any wall the body rests against; a browser test covers that
case, and `FRICTION.md` records the cause. It also reaches two pixels down into
the slide box as a tolerance for where a resting body settles, which no test
discriminates.
Callers register contacts through `onCollision` / `onTrigger` on the component,
never on the `ColliderComponent`. Each replacement disposes those subscriptions,
removes the old component, adds the new collider, and re-registers every handler
in the same synchronous call.
A replacement ends nothing. The engine drops events for a collider that has
been removed, so a contact the old collider was touching never reports its end,
and the new collider reports fresh starts for whatever it touches. `onReplaced`
is how anything holding a record of what the body touches is told to drop it;
`PlayerContacts` is the one subscriber.
Not implemented yet: the forced restore after death, load, and section
transition, which `TDD.md` assigns to the same replacement path.
#### Implemented: `PlayerContacts`
`src/features/player/PlayerContacts.ts` answers what the body is touching and
writes nothing to it. Contacts arrive through `PlayerStance.onCollision`, so a
stance change carries the subscription to the new collider. The derived facts
are refreshed once per fixed step, in the classify stage, before any rule reads
them.
Five of its rules exist because a contact alone does not say what the body is
doing.
- **Where a contact sits is measured, not recorded.** A collision event carries
a normal only where a pair starts touching, and `@yagejs/tilemap` turns a
Tiled polygon into one polyline collider, so a body crossing a mound from its
cap onto its ramp is never told the surface changed — and a body that walks
off a block's top onto its side is never told either. Four `castShape` probes
each fixed step — below, above, and one to each side — file the contacts they
reach as ground, ceiling, or a wall on one side, and report the ground normal.
Each probe is the body's own box shrunk in from the faces it does not ask
about, because a wall pressed against the body's side is a zero-distance hit
for a full-width downward cast. The events keep the set of entities the body
touches, so a probe that reaches a surface the body is not against cannot make
it ground, and a contact no probe reaches keeps the classification its own
normal gives it.
- **Ground is a contact the body is not leaving.** A jump takes effect while
the body still touches the floor, and replacing the collider for a stance
change gives the new collider its own contact with that same floor. Ground
therefore also requires that the body is not moving away from the surface
faster than the surface itself moves. The relative form is what lets a
descending platform carry a rider without reading as separation. The rate that
counts as leaving follows the speed the body carries along the ground — the
steepest walkable transition lifts a body travelling at `speed` by
`speed * tan(limit)` for one step — with a 60px/s floor for a body at rest.
It stays below the slowest deliberate departure, the 300px/s double jump.
- **Separation is read per step, never recorded.** A body squeezed off its
platform and pressed back onto it never ends the contact, so a support
discarded on the first separating step would never come back.
- **A landing snap holds ground for one extra step.** The snap places the feet
at the end of a step and the contact it produces is reported by the step
after, and without the hold that gap reads as leaving the ground.
It also answers the two correction candidates the motor may act on: the
smallest sideways offset that frees a head caught on a ceiling edge, from
`PhysicsWorld.queryShape`, and how far down the feet may snap onto valid
ground, from `PhysicsWorld.castShape`.
#### Implemented: `PlayerMotor`
`src/features/player/PlayerMotor.ts` owns the player's locomotion state, its
movement resources, and the motion committed for the next physics step. It
implements the `CameraSubject` the camera reads.
**Requests are resolved once per fixed step.** Intents and edges arrive during
the variable update and are held in one request record, so several fixed steps
inside one frame cannot admit the same press twice. The motion lane order is
the one `TDD.md` fixes: hurt, dash, down, wall jump, ground or air jump,
normal. The first admissible request wins and the rest of the sample is
dropped.
**It writes nothing to the body.** Each step it submits one command to
`MotionCommitter`, which is what makes one motion writer per body a checkable
property rather than a convention.
**Down on the ground either slides or crouches.** Which one depends on the
speed already carried, and a slide is one burst per press: it ends into the
crouch rather than into a state that could start it again. `slide` is the fast
low burst, `crouch` the low walk that a spent slide or a low ceiling leaves the
body in. A dash out of either keeps the low box, because standing up inside a
passage the body is already in puts the taller shape in the geometry. The
crouch stands up the moment the standing box fits, in the air as on the ground:
the clearance test is the whole question.
**Hurt and the protected windows reject rather than queue.** A hit refuses every
ordinary request while it holds the body, and a dash refuses a request made
inside a wall impulse's steering lock. A dash itself ends on its duration,
against terrain that stops it, or on a cancel: a jump or an attack past its
protected startup ends it and carries `dash.cancelSpeedInheritance` of its
speed out — a fraction of the dash speed, so a stronger dash carries more,
without the cancel launching at the dash's own speed. The carried speed lasts
for the whole airborne arc while the direction is held: steering never pulls a
body back toward the run cap in the air when it is already faster in the held
direction. Landing, releasing the direction, turning, or a committed action's
hold is what reclaims the excess.
**Standing up is a question about where the body is going.** The clearance test
is swept by the step's travel, because a body a pixel short of a low passage
has clearance where it stands and none a step later. `FRICTION.md` records what
that does not cover: a body held into an overhang rests about two pixels inside
it, which is the commanded speed being re-applied against the solver's
push-out.
**A wall has to be gripped before it can be jumped from.** It takes an airborne
body, a wall, the direction held into it, and `wall.minGripTime` on it. A ledge
brushed in passing is not a grip, and the wall-slide state uses the same test,
so what the player sees is exactly when the move is available.
**The facing follows the held intent every step**, not only when the intent
changes, so a direction held through a dash or a knockback takes effect the
moment the body can steer again. `holdFacing` pins it against the intent, which
is what a reel or a knockback does and what the camera's lead rule reads.
`src/features/player/playerTuning.ts` holds every number the moves are measured
in, grouped by move, and `tunedPlayer` overrides one group at a time. The gym
lane exposes them as lab controls.
`GrappleController` owns the player's attachment record and uses
`PlayerMotor`'s reel and caught motion owners. `CaughtController` routes root
placement through `MotionCommitter`'s `teleport` command, and dummy patrol and
hover motion use the same queue. The target registry uses the motor's movement
vector and keeps the last non-zero direction when movement is neutral.
One attachment passes through five phases, and `GrappleController` owns every
transition between them. `hookFlight` is the authored flight a latch on a
manipulable enemy plays before anything resolves; a terrain or heavy anchor
skips it, and `playsHookFlight` is the one place that rule lives. It answers
from what the target is rather than from whether a duration was tuned, so the
hook flight's lane control reaches zero without changing which technique a
latch opens. `connected` is
the held connection, in which the player is free and the enemy is pinned.
`reel` is the player's own flight: to an anchor, as a catapult toward an enemy,
or as a propel slam past one. `pull` is the enemy's flight to the catch socket.
`caught` is the sensor hold at that socket. `PlayerMotor.AttachmentState` carries `hookFlight`
and `heldConnection` alongside the three target classes, so the attachment
lane reports the phase rather than only what is on the other end of the
tether.
`CaughtController` owns the enemy's body in three of those phases through one
mode field: `held` keeps it solid and pins it where it stood, `pull` flies it
solid toward the socket, and `caught` is the sensor hold. `moveHold` moves the
point `held` pins the body to and `holdPosition` reports it, which is what a
draw-back writes to: the body stays solid, and no catch timer starts.
It subscribes to
both the collision and the trigger channel, because a sensor reports on the
trigger one and the caught body is a sensor — a solid overlap while caught is
the invalid collision that ends the catch. Motion authority returns to the
enemy one fixed step after a release, and the enemy additionally yields while
its pressure episode is airborne or in landing recovery, which is what stops a
patrol replacing a throw's velocity.
The techniques are separate entry points rather than one dispatcher:
`connectedPull`, `throwConnected`, `catapult`, `catchStrike`, and
`throwCaught`. `PlayerActionInput.routeAttachmentPrimary` chooses between the
two techniques of each phase by whether any 8-way direction is held, and a
second grapple press while connected catapults instead of being refused.
`canReel` reports whether the attachment is still moving a body, which is
every phase but the held connection; `canTarget`, `canHit`, and `canObserve`
are unchanged.
`throwConnected(direction)` is the single entry point the directional press on
a held connection routes through, and it dispatches on the throw mode the
target class's launch row names. `catchThrow` runs `slam`, which launches on
the press. `pullPush` opens a windup that walks the held enemy back against the
aim and ends in the same launch from the point it reaches. `propelSlam` starts
a player flight aimed past the enemy and launches it on the fixed step the
flight passes it. `slam` and `throwCaught` stay public, because mechanical
situations drive each of them directly. `TDD.md` "Launch profile" owns the
three behaviours and what is shared between them.
The propel flight's pass is tested before the held enemy's own step, so the
enemy takes one command on the pass step — the launch — and the committer's
one-command-per-body rule has nothing to refuse. The test is gated on the enemy
still being held, so an enemy killed on that step reaches its own death
handling. The launch ends the attachment through `detach` rather than
`clearAttachment`, because the reel still owns the motor and only `detach` ends
that flight and hands the player its capped release carry.
The draw-back distance, the flight's overshoot past the enemy, and the pass
offset live in `playerTuning.grapple` beside the throw windup the draw-back
spends. `withThrowMode` in `combatTuning.ts` writes one mode into both class
rows, or returns the table untouched for a null mode, which is what the
manipulation lane's mode control drives.
Every fixed-step grapple measurement reads the simulated pose through
`PlayerMotor.simulatedPosition`, which is what the rest of the fixed step
works in. Selection is the exception and reads the drawn pose, because it
answers a question about what the player was looking at when the press landed.
The leash and the reel's destination are the same point: the target's position
plus its attachment offset.
The two selection angles live in `playerTuning.grapple` as `aimConeDegrees`
and `aimAssistDegrees`, so the selector and anything that draws the cone read
one number. `TargetRegistry` still takes both through its config for a scene
that wants its own width, and falls back to the tuning when they are omitted.
`GrappleController.serialize` reports the running phase's remaining time in
`phaseLeft`, including the caught phase, whose timer `CaughtController` owns
and drops the enemy on. `PlayerActionInput.primaryTechnique` answers what the
primary press runs from the current state, and `routeAttachmentPrimary` routes
the press by that same answer, so a readout naming the button's meaning cannot
disagree with what the button does.
`src/features/presentation/ImpactPresentation.ts` is the one owner of hitstop
and the hit flash, hosted on its own entity by `ImpactPresentationEntity`
because a freeze is whole-scene and a flash belongs to whatever was struck.
Every freeze goes through one named `SceneTime` channel, so two impacts in the
same beat hold for the longer of the two rather than for whichever landed
last. `PressureState` reports an accepted impact through an `onImpact`
callback, so the episode ledger never depends on presentation. The other freeze
input is the attack definition's own `hitstopPreset`, which
`PlayerActionInput` spends from its `HitDealt` listener without flashing
anything.
Four call sites fire the flash, one per way a body can be made to pay health.
`CombatantRoot.receiveFrom` flashes its combatant when the receiver fold
answers `"hit"`, which covers every delivery the pipeline carries — a swing, a
shot, a discharge, a guard's punish, a contact aura. The first of the player's
receiver apply stages in `playerHitSteps` flashes the player, because reaching
an apply stage is what a landed hit means for a receiver whose stages the game
writes. `EnemyImpactCoordinator.step` flashes both bodies of an enemy-to-enemy
collision, and `CaughtController.strike` flashes the caught body; neither of
those two costs goes through the hit pipeline, so nothing else would report
them.
`ImpactPresentation.flash` resolves the visuals that stand for a body once and
keeps the handles, because the effect is a filter on a visual rather than a
one-shot. It collects every `VisualComponent` in the body's own subtree, or in
the subtree of the nearest ancestor that draws anything: a hit can name a
hurtbox, which is a bare collider, and the player draws from one child entity
per animation sheet with one of them visible. A body nothing draws counts no
flash, so a composition without art reports none.
A freeze holds real time and stops the physics step, but the fixed-step stages
keep being invoked at zero `dt`, so every rule under them measures elapsed
simulation rather than counting calls: the launch profile's two segments, both
flight stalls, the connection and catch timers, the pressure step counter, the
enemy's actionable-armor decision, and the caught body's queued release all
advance on `dt`.
The player's rigid body is built with `ccd`. A reel at the top of its tuning
range travels further in one fixed step than the body is wide, so a thin wall
would otherwise fall between two discrete samples and leave nothing for the
solver, the re-aim, or the stall to act on. `addPlayerRig` records the
requirement, and every scenario that composes a player meets it.
Grapple selection follows the TDD four-stage order: narrow-cone membership,
angular difference, distance, and stable id. Assist widens admission without
changing those ranks. The grapple origin is a tuned point above the player's
foot transform, and preview and fire pass that same point to the registry for
angular ranking and scene-physics occlusion. The registry excludes the player
entity from its ray; a near-zero boundary hit on that excluded body is not
world occlusion, while a near-zero hit on other solid geometry remains a
blocker. Narrow-cone membership uses the same angle threshold as angular rank,
so its comparator is a faithful TDD stage with no independent outcome to
mutate. The registry uses the scene physics ray for occlusion and unregisters
targets when their owning entities are destroyed. Attachment validity compares
the entity generation, the section generation, and whether the registry entry
still admits a latch at all — a disabled or immune entry ends the tether it is
already carrying rather than only refusing the next press. `canTarget`,
`canHit`, `canReel`, and `canObserve` respectively report attachment validity,
live hurtboxes, whether the attachment is still moving a body, and an enabled
observable target. The leash is the shorter of the player's reach and the
target's own `maxRange`, measured at the attachment point the reel steers to.
Attachment ownership has two layers. Section movement, target retirement,
target destruction, invalid target state, range loss, and caught-body exits
release target-owned motion and increment `detachCount` once, but retain the
stale attachment record. The four queries refuse while that record is stale.
Arrival, a blocked flight, a connection timeout, a slam, and a throw are
player-side ends instead, and they clear the record outright.
The caught controller restores a caught root's solid collider in the same
fixed step as the placement the release resolved, not at the release call: no
physics step runs between the two, so the body is never stepped as a solid at
the point it was released from. Explicit player release, player damage, player
death, player transition, and throw completion clear the record. A new
acquisition also clears a stale record before it attaches the new target.
Repeated invalidation frames do not increment the count again. Clearing an
already stale record does not add another motion detach.
The grapple releases in this order: damage, death, transition, target
destruction, range, invalid collision, and explicit release. Player hurt and
control death produce the corresponding player-side detach; enemy death and
invalid solid contact produce the caught-side detach. A throw remains attached
through its windup and releases on its active frame. A release point is checked
against the physics world, with the current body position as the safe fallback.
Release velocity is capped by `playerTuning.grapple.releaseSpeedCap` and
carried into the next motor step. The motor pins ranged and power aim to the
reel-facing direction while the reel owns facing; movement-vector aim resumes
after release.
The Riot Walker delays its AI decision for two fixed steps so actionable armor
has an observable window. `EnemyImpactCoordinator` runs in the enemy stage and
captures each participant's stored pre-physics speed when the collision pair is
queued; equal speeds use stable id order. Tether Siphon is outside this core;
Tether Discharge is the attached contextual power and Rail Shot is the
unattached choice.
#### Implemented: the action lane's combat composition
The action lane has abilities behind it. `src/features/combat/` holds the
pipeline: `hitPipeline.ts` is the hit vocabulary (`CombatHitData` with the
attack-instance and pulse identity), `HitLedger` the one-receipt rule,
`CombatantRoot` a combatant's receipt identity and apply stages,
`HurtboxEntity` the sensor part that delegates to it, `PressureState` the
episode and hitstun bookkeeping with the pre-physics velocity sample,
`ProjectileEntity` the basic and piercing shot, and
`abilities/playerAbilities.ts` the player's definitions — the three-hit
string, the aerial pair, the launcher, the down strike, the slide strike,
ranged, the rail shot, guard with its parry window, and the grounded heal.
`src/features/player/` holds the player's side: `PlayerActionInput` resolves
presses through access, energy, motor admission, then `Abilities.send`;
`Energy` runs the reserve/commit/refund transactions; `AbilityAccessPolicy`
derives allowed action ids from progression facts. The player's `HitReceiver`
takes custom apply stages — damage through `Health`, the reaction through the
motor's `hurt` owner — because the addon's `Stagger` writes the body's
velocity directly and would collide with `MotionCommitter`'s
one-command-per-body rule. Enemies keep the addon's default stages, with zero
i-frames: a post-hit invulnerability window would refuse the second hurtbox
of the same swing.
Three motor rules carry the committed actions: `setActionHold` caps steering
and refuses dash, jump, and the down intent while a heal channels or a rail
charge holds; `applyPushback` is a blocked hit's one-step shove without the
hurt lock; `applyBounce` is the down strike's once-per-instance upward
response. One action lane at a time: `requestAction` refuses a second lane
until the first ends.
### Combatants
| Entity/component | One responsibility |
| --- | --- |
| `CombatantRoot` | Own stable `combatantReceiptId`, the hit ledger, and the receiver's apply stages |
| `HurtboxEntity` | Sensor delivery that delegates to one combatant receipt ID |
| `HitReceiver` | YAGE outer receipt fold and game apply stages |
| `Posture` | Accumulate, break, recover, and expose stagger threshold |
| `ManipulationState` | Weight class and current pull/launch resistance |
| `PressureState` | Diminishing hitstun, impact flags, recovery armor, and pre-physics velocity sample |
| `EnemyImpactCoordinator` | Read two pressure states once in the enemy stage, choose one impact owner, and apply both collision damages |
| `MotionAuthority` | Admit exactly one motion writer |
| `EnemyBrain` | Choose behavior state, goal, attack, and telegraph |
| `SteeringAdapter` | Convert one approved goal into desired airborne/free movement |
| `BossPhaseController` | Enter and exit one boss phase and clean its children/commands |
| `ProjectileEntity` | Own one basic, piercing, or reflectable projectile lifecycle |
| `ProjectileController` | Own section-scoped projectile creation, lookup, and retirement submission |
#### Implemented: `CombatantRoot`, `HurtboxEntity`, `PressureState`, `ProjectileEntity`
`src/features/combat/` holds the four. `CombatantRoot` owns the receipt
identity, the ledger keyed by attack instance, pulse, and hurtbox, an
optional section gate that refuses deliveries once the combatant's section
moved on, and `hitSteps()` — the apply stages its sibling `HitReceiver` runs:
ledger, part multiplier, pressure mutation, damage, then the reaction. A hit
carrying a `launch` vector reacts as a ballistic launch through
`PressureState`; anything else takes the addon's stagger ramp.
`HurtboxEntity` is one part: a kinematic sensor placed at the root each fixed
step, delegating every delivery to the root with its own hurtbox id and
damage multiplier. Each part is its own entity because that is the addon's
delivery granularity — a swing covering two parts lands on both.
`PressureState` owns the launch episode: one launch award, one wall-impact
award, one enemy-impact award, the pressure count that diminishes hitstun
toward a floor, and the pre-physics velocity sample the wall-impact rule
judges contacts by. Its `step` runs in the scheduler's combat stage.
`EnemyImpactCoordinator` resolves a queued enemy pair once in the enemy stage,
with the larger stored approach speed owning the impact and stable ID breaking
ties. The coordinator does not call collision handling recursively.
`ProjectileEntity` is the game-owned shot: kinematic, moved by a `Transform`
write per fixed step, delivering from an overlap query each step so the
victims' ledgers hold a pass to one receipt. A basic shot is consumed by its
first processed delivery; a piercing one only by solid terrain. It delivers
from its launch origin, so a guard cone judges the flight direction rather
than the contact sliver. The addon's `Projectile` is not used: it is consumed
by any non-ignored result, a blocked hit included, which is the opposite of
piercing.
Not implemented: `Posture`, `ManipulationState`, `MotionAuthority`,
`EnemyBrain`, `SteeringAdapter`, `BossPhaseController`, and
`ProjectileController`. The gym's target dummies are scenario entities that
compose the production combat and grapple components with fixed behaviors.
### UI and presentation
| Entity/component | One responsibility |
| --- | --- |
| `HudEntity` / `HudProjection` | Read narrow state and render field HUD |
| `MenuRootEntity` | Own one scene's UI React root |
| `MenuFocusController` | Own stable focus ID, neighbors, repeat, confirm, cancel, and busy state |
| `MapProjection` | Convert surveyed world data to minimap/full-map drawing data |
| `GameCamera` | Assemble the camera so the director runs before the engine's follow and clamp |
| `CameraDirector` | Own look-ahead, vertical anchor, section bounds, and arrival snap |
| `SectionAudioRouter` | Select area music, ambience, and transitions |
| `CombatFeedbackRouter` | Convert typed combat events to presentation only |
| `SubtitlePresenter` | Show authored subtitle events under profile settings |
### Camera composition
`GameCamera` in `src/features/presentation/` is the game's camera: an engine
`CameraEntity` with a `CameraDirector` added in front of it. One frame runs
four stages, and only the first is game code:
1. `CameraDirector.update` writes the focus point into the camera entity's
`Transform`. Four rules decide it, and all four exist to keep the view from
moving in ways the player did not ask for:
- **A window.** The focus follows only far enough to keep the subject inside
`windowHalfWidth` of where the lead asks. Inside it the view holds still.
- **A lead that grows with speed** toward `lookAhead`, bounded by
`maxLookAhead`, and **a turn distance**: the lead keeps pointing the way
it pointed until the subject has carried it `turnDistance` the other way.
A facing change alone does not reverse it.
- **Asymmetric damping.** Growing the lead uses `lookAheadSmoothTime`;
giving it back uses the longer `lookAheadReturnSmoothTime`, because a
camera sweeping against the subject's new direction is the motion that
makes a camera uncomfortable to watch. `maxCatchUpSpeed` caps both.
- **A vertical anchor with its own window.** The focus holds the last
landing height until the subject leaves it by more than
`verticalThreshold`, and `windowHalfHeight` is the leeway around it: a
landing settles over several steps as the solver pushes the body back out
of the surface it sank into, and a camera tracking the height exactly
rides that settling as a tremble. A camera hint can close the window —
the shaft hint nearly does, because vertical motion is a shaft's content.
2. `CameraFollow`, started with `smoothing: 1`, copies that `Transform` into
`CameraComponent.position`. All damping stays in the director, expressed in
seconds through `MathUtils.smoothDamp`. `CameraFollow`'s own smoothing
factor is per reference frame, so a value cannot be read as a time.
3. `CameraBoundsComponent` clamps that position to the section bounds.
4. `CameraShake` produces an offset that only `effectivePosition` adds, at
render time, so shake never reaches a world coordinate a rule reads.
Two properties make that pipeline hold, and both come from the entity's
construction rather than from anything the engine enforces:
- **The director is added before `super.setup()`**, and therefore before
`CameraFollow` and `CameraBoundsComponent`. Components update in add order,
so the focus is written before the follow reads it, and the clamp runs after
the follow. A director added afterwards lags the focus by one frame, which
the drawn-distance measurement in `tests/browser/camera.spec.ts` catches.
Holding all four components on one entity is what makes that order local:
split across two entities, the same order would depend on which entity was
spawned first, which nothing in the engine expresses.
- **The follow target is a real `Transform` on a real entity.** `CameraFollow`
saves a target with no `entity` back-reference as a fixed point and one with
an `entity` as that entity's `Transform`, so only the second form comes back
still moving. A loaded run therefore needs nothing re-applied. The camera entity has to stay a scene
root, because `CameraFollow` reads `position`, which is parent-relative.
The subject the director reads is a `CameraSubject`: a drawn position, a
horizontal speed, a facing, and whether it stands on a support. `PlayerMotor`
implements it, and the camera scenarios follow the same body the gym does. The drawn position is the body's `Transform`: physics interpolation
writes the drawn pose there at the start of the update phase, before any
component reads it, so the camera and the body it follows are read from one
clock.
`GameCamera` takes optional `bindings`, which is how a layer gets parallax
through its `translateRatio`. The camera scenarios put a ruled grid on a
background layer at half the camera's translation, because a moving view and a
moving body look the same without a marked background.
Section bounds are set through `CameraDirector.setBounds`, which writes them to
the sibling `CameraComponent` and keeps no copy. Camera hints go through
`setHint(id, overrides)` and `clearHint(id)`. A hint replaces any subset of the
focus rules, which is how a vertical shaft changes the vertical follow without
touching the section's constants. Only the newest hint applies: an older one
stays active but is shadowed whole, and takes effect again when the newer one
is released.
## Communication
### Direct references
Composition roots pass direct references for synchronous commands:
- input calls `AbilityAccessPolicy` and `Abilities`;
- grapple calls the chosen target capability and player/enemy motion owner;
- `WorldDirector` coordinates the specialized controllers referenced by
`SectionRuntime` and sends their retirement submissions to
`SectionRetirementController`;
- `SectionSpawnRouter` calls the matching encounter, boss, NPC/shop,
collectible, or mechanism factory and transfers the returned root
immediately; each recipient owns and retires only its domain;
- pause terminal commands call `SaveCoordinator`, `LoadCoordinator`, or `WorldDirector`;
- HUD and menu projections read narrow persistent/run interfaces.
## Section authoring
Each section is one finite, orthogonal Tiled JSON map with flat tile/object
layers and JSON tilesets. It is the authoritative editable source for metadata,
visual layers, collision, arrivals, exits, spawns, checkpoints, Grapple
anchors, mechanisms, encounters, survey geometry, camera regions, and audio
regions. One point object on the `metadata` layer carries section identity and
full-map atlas coordinates through YAGE's supported object-property API.
Exit objects remain the only source of graph connections. Raw-file preflight rejects
infinite/chunked maps, group layers, unsupported orientations, and TSX
tilesets. The exact layer/class/property schema is in `TDD.md` under “Tiled
section convention.” Reusable combat, enemy, item, shop, quest, reward, and
dialogue rules stay in content catalogs and are referenced from Tiled by ID.
No TypeScript file duplicates exit connections or object placement.
The catalog contains 27 section maps: `T0`–`T8`, `F0`–`F8`, and `B0`–`B8`.
Their exits produce 43 reciprocal connection pairs. Six pairs cross area
boundaries; four of those are return links that open later without bypassing
the ordered Grapple, Double Jump, and wall-movement progression.
### Parser contract
`parseSectionMap(raw, { sourceFile, knownArchetypeIds })` returns a result
object and never throws on map content, so the boot parser reads all 27 files
and reports every bad map in one pass. A rejection carries every diagnostic
the map produced. Each diagnostic names a stable code, the file, the field or
object, the problem, and the supported replacement.
`buildWorldGraph(sections)` returns the same shape for cross-map rules:
reciprocal exits, destinations present in the catalog, and non-overlapping
atlas placement.
Five rules the convention table in `TDD.md` leaves to the parser:
- `metadata` is the only required layer. Every other layer is optional, and a
section with no exits parses; connectivity is the world graph's rule, not the
map's.
- An exit's `arrivalId` names an arrival in its **own** section: where a player
entering through that exit lands. The far side resolves through
`destinationExit`, so `WorldGraph.transition()` reports the destination's own
arrival. No map names an object in another map.
- `knownArchetypeIds` is optional. Spawn and mechanism archetype references go
unchecked while no content catalog is loaded.
- An audio region names at least one of `musicId` and `ambienceId`. A region
naming neither would be a rectangle with nothing to play.
- A camera region of class `hint` requires both `hintMode` and `lookAhead`;
class `bounds` accepts neither.
- A rotated object is rejected on every layer but `collision.solids`, which is
the only one whose reader applies rotation. Elsewhere a rotated rectangle
would silently become an upright one.
A section is either fully valid or rejected. Every check reads the objects the
map authored, so an object rejected for one mistake still counts toward what
the section defines, and records are built only after every check passes.
A connection carries no direction and no combined secret flag. Its two ends are
sorted by section and exit ID, so the pair is identical whatever order the
catalog is read in, and each `ExitRecord` keeps its own `secret` value because
a passage can be hidden from one side and visible from the other.
Fixture maps live in `tests/fixtures/sections/`: `alpha.json` authors every
layer the convention defines, `beta.json` closes both of its connections,
`graph/` holds maps that parse but fail world assembly, and `invalid/` holds
one map per rejected form.
### Typed entity events
Use `defineEvent()` for accepted facts with several observers:
- `Landed`
- `MovementAbilityUsed`
- `HitAccepted`
- `HitGuarded`
- `LaunchStarted`
- `WallImpactAccepted`
- `EnemyImpactAccepted`
- `RecoveryStarted`
- `GrappleStarted`
- `GrappleEnded`
- `EnergyChanged`
- `CheckpointActivated`
- `CollectibleGranted`
- `ShortcutOpened`
- `ObjectiveChanged`
- `BossDefeated`
- `SectionTransitionStarted`
- `SectionTransitionCompleted`
- `RunReplaced`
Each listener registers once and retains its disposer. Section-owned listeners are canceled before retirement.
### Global communication
Avoid the global event bus. `ProfileDocument` changes may use one genuinely global profile event when title, world, and pause screens all need it. Run replacement uses the persistent run owner or world entity, not a general-purpose bus.
## Controls
### Keyboard
| Action | Default |
| --- | --- |
| Move and aim | WASD |
| Slide | Down while running |
| Jump | Space |
| Dash | Left Shift |
| Melee / interact | J |
| Ranged | K |
| Grapple | L |
| Guard / parry | I |
| Contextual power | U |
| Heal | H |
| Map | Tab |
| Pause | Escape |
### Gamepad
| Action | Default |
| --- | --- |
| Move and aim | Left stick or D-pad |
| Slide | Down while running |
| Jump | South face button |
| Melee / interact | West face button |
| Contextual power | North face button |
| Heal / cancel | East face button |
| Guard / parry | Left bumper |
| Dash | Right bumper |
| Ranged | Left trigger |
| Grapple | Right trigger |
| Map | View |
| Pause | Menu |
The right stick has no gameplay action. Raw left-stick reads obey the game-owned input mode. Every binding is remappable; mouse input is optional for UI.
When the Map tab owns focus, left stick pans, D-pad selects markers, triggers
zoom, west centers the player and resets zoom, north centers the objective,
south confirms, east clears or leaves map focus, and bumpers change tabs. The
Map input group consumes these actions before paused gameplay can read them.
## Asset groups
- Luis Zuno: the installed player family, the `cyberpunk-city` building set and its signage, the `sewers` and `warped-caves` sets for the other two areas, plus drone, turret, vehicles, plasma, explosion, and city parallax.
- Kenney: particles, UI, input prompts, locomotion and interface SFX.
- Not Jam: three-font UI family.
- Kronbits: combat, machinery, transitions, stingers, pickups, and ambience.
- HydroGene: area and boss music.
- Game-drawn gap treatments: grapple line/head, map shapes/markers, area structural backing, masks, signal bands, target outlines, and limited boss framing.
Exact IDs, author boundaries, proofs, and gaps are in `ASSET-PLAN.md`.
## Save shape
Run slots are `manual-1`, `manual-2`, `manual-3`, and `auto`.
```text
RunSnapshot
schemaVersion
core
progressionEvents
openedShortcuts
collected
equippedModules
equipCapacity
healthUpgrades
energyUpgrades
currency
discoveredMapCells
knownExits
knownBlockedRoutes
revealedSecrets
activatedCheckpoints
respawn
journalMessages
modules
inventory slots
quests
active/completed objective state
```
Major boss defeat, Grapple, Double Jump, wall movement, route reveal, and main
objective repair derive from canonical progression reward events. They are not
stored as independent truths.
The profile document stores audio, display, accessibility, text, aim assist, hold/toggle choices, and exported bindings. Run slots never store profile settings.
Transient state is never saved: live entities, current health/energy if checkpoint rules restore them, velocities, attacks, cooldown phases, grapples, catches, projectiles, pressure episodes, current ordinary-enemy defeats, boss attempt phases, transition state, UI focus, or open scenes.
## Update rule
After every implementation milestone, compare the live tree, scenes, entities, components, communication channels, controls, assets, and save shape with this file. Update the manifest before handoff when implementation changes any entry.