← all games

metroidvania-action-platformer-gpt-5-unknown — FRICTION.md

# Friction log

## 2026-07-31 — local `file:` packages confuse the recursive npm audit

- Category: infra/test gap
- Trying to do: confirm that the complete dependency graph uses the sibling
  YAGE checkout.
- What happened: `npm ls --all --json` returned `ELSPROBLEMS`. npm followed
  each local symlink into the YAGE workspace and treated the package's
  development dependencies as missing from the game install.
- Evidence: the errors included `@vitest/coverage-v8` from local package
  `devDependencies` and local engine packages that are present as direct game
  dependencies.
- Workaround: use `npm ls --depth=0 --json`, inspect every installed YAGE
  package's real path, inspect the lockfile's `resolved` entries, and run the
  production build. All four checks confirmed local resolution.
- Status: worked around. A recursive npm audit remains noisy for symlinked
  workspace packages.

## 2026-07-31 — rigid-body gravity scale cannot change at runtime

- Category: API friction
- Trying to do: tune variable jump, faster falling gravity, fast fall, and
  temporary movement states on one player body.
- What happened: `RigidBodyConfig.gravityScale` is accepted when the body is
  created, but `RigidBodyComponent` exposes no public runtime gravity-scale
  setter even though the underlying Rapier body supports one.
- Evidence: `../yage/packages/physics/src/types.ts`,
  `../yage/packages/physics/src/RigidBodyComponent.ts`, and
  `../yage/packages/physics/dist/index.d.ts`.
- Workaround: keep the scene gravity constant and add game-owned downward
  acceleration for falling and fast-fall states.
- Status: fixed upstream and available here in `@yagejs/physics` 0.10.1
  through yage PR #228: `RigidBodyComponent.setGravityScale(scale)` plus a
  `gravityScale` getter. Nothing in this game uses it yet; the Milestone 2 jump
  is where it lands. Before it, each platformer had to reproduce body-specific gravity
  control.

## 2026-07-31 — collider shape cannot resize in place

- Category: API friction
- Trying to do: shorten the player collider for the running slide and restore
  it safely afterward.
- What happened: `ColliderComponent` exposes `setSensor`, but no public shape
  or size update. The player cannot keep standing and sliding instances of the
  same component class on one entity.
- Evidence: `../yage/packages/physics/src/ColliderComponent.ts` and
  `../yage/packages/physics/dist/index.d.ts`.
- Workaround: `PlayerStance` must dispose the old subscriptions, remove the
  collider component, add a new collider, and register new subscriptions.
  Standing still requires a separate clearance query.
- Status: partly fixed. `@yagejs/physics` 0.10.1 adds
  `ColliderComponent.setShape(shape, options?)` through yage PR #228, which
  keeps the Rapier collider, its body attachment, and every subscription. It
  does not carry the stance change on its own — see the offset entry below — so
  `PlayerStance` still replaces the component, and the replacement lifecycle
  and contact continuity add risk to a common platformer action.

## 2026-07-31 — no per-pair one-way collision policy

- Category: missing primitive
- Trying to do: let the player and selected enemies land on a one-way platform
  from above, pass from below, and drop through without changing the platform
  for every other body.
- What happened: collision layers and `setSensor` apply to the whole collider.
  YAGE exposes no per-body-pair solver filter or pre-solve contact decision.
- Evidence: `../yage/packages/physics/src/ColliderComponent.ts`,
  `../yage/packages/physics/src/PhysicsWorld.ts`, and the public physics
  declarations.
- Workaround: use a dedicated sensor plus a game-owned crossing and support
  solver. Keep one-way platforms out of required routes until the two-body,
  fast-fall, edge, and drop-through cases pass.
- Status: fixed upstream and available here in `@yagejs/physics` 0.10.1
  through yage PR #231: `ColliderConfig.oneWay`,
  `ColliderComponent.dropThrough(seconds)`, and `setContactFilter(filter)` as
  the primitive underneath. Not adopted yet; it replaces the planned one-way
  probe with an adoption step.

## 2026-07-31 — physics queries have overlap tests but no public shape cast

- Category: missing primitive
- Trying to do: sweep the player or a support shape through motion for moving
  platform carry, crush prevention, fast landing forgiveness, and safe stance
  changes.
- What happened: `PhysicsWorld.queryShape` reports current overlaps and
  `raycast` reports a ray hit, but the public API has no swept shape cast.
- Evidence: `../yage/packages/physics/src/PhysicsWorld.ts` and
  `../yage/packages/physics/dist/index.d.ts`.
- Workaround: combine several rays, bounded overlap queries, stored previous
  positions, and conservative motion. Remove any required case that cannot be
  made stable in the focused probe.
- Status: fixed upstream and available here in `@yagejs/physics` 0.10.1
  through yage PR #228:
  `PhysicsWorld.castShape(shape, origin, direction, maxDistance, options?)`
  returns the existing `RaycastHit`. Nothing in this game uses it yet; crush
  detection and landing forgiveness are its first callers. Before it, high-speed and crush behavior needed direct
  testing.

## 2026-07-31 — selected cyberpunk player has no melee animation

- Category: asset gap
- Trying to do: cover grounded and aerial melee, the launcher, catch, and
  throw with a coherent player sheet.
- Search terms: `melee`, `sword`, `slash`, `attack`, `cyberpunk player`, and
  `side-view fighter`.
- What happened: the Luis Zuno player family covers locomotion, hurt, and
  shooting, but no matching side-view melee sheet exists in the catalog.
- Workaround: use the approved player poses with positional animation and
  Kenney slash particles. The asset proof must reject this treatment if timing
  or reach is unclear.
- Evidence: `scenarios/player/art.scenario.ts` draws the `melee` state from
  `cyberpunk-player-punch`, and `scenarios/player/sheetLibrary.scenario.ts`
  plays every installed sheet.
- Status: closed on 2026-08-05. The library's Warped City Addon adds punch,
  kick, upper kick, crouch kick, and throw from the same author, and the
  `melee` state draws `cyberpunk-player-punch`. The workaround was never built.
  The close is conditional: that pack's licence is `PENDING` in the library, so
  its sheets are usable for prototyping and must not ship. `PLAYER_SHEETS` in
  `src/features/player/playerAssets.ts` marks each one `licence: "pending"`. If
  the artist refuses CC0 terms, this gap returns with the search history above.

## 2026-08-03 — selected cyberpunk player has no guard/parry animation

- Category: asset gap
- Trying to do: cover guard and parry with a matching Luis Zuno player pose.
- Search terms: `guard`, `parry`, `block`, `cyberpunk player`, and
  `side-view fighter`.
- What happened: `CATALOG.md` has no matching Luis Zuno player sheet for a
  guard or parry state. The installed player family has no state-specific pose.
- Workaround: use `cyberpunk-player-idle` as the approved neutral guard base and
  communicate guard and parry through runtime effects and timing.
- Evidence: `scenarios/player/art.scenario.ts` captures `guardParry` from the
  idle atlas and labels it as an approved base pose. It does not draw a player
  replacement pose.
- Status: open, with the candidate chosen. Re-checked against the Warped City
  Addon added on 2026-08-05, which closed the melee gap and left this one.
  `cyberpunk-player-cover` and `cyberpunk-player-cover-crouch` are the intended
  guard and parry poses, standing and low, and the user picked them on
  2026-08-05. Each is two frames of a body pressed sideways behind cover rather
  than a forward block, so the guard cone and the parry window still need the
  drawn arc cue to be legible. The combat art step tries the pose and the combat
  lane's check packet judges it.

## 2026-08-03 — selected cyberpunk player has no grapple-latch animation

- Category: asset gap
- Trying to do: cover the player pose while a grapple target is latched.
- Search terms: `grapple`, `latch`, `tether`, `cyberpunk player`, and
  `side-view fighter`.
- What happened: `CATALOG.md` has no matching Luis Zuno player sheet for a
  grapple-latch pose. The separate cable and hook gap is recorded below.
- Workaround: use `cyberpunk-player-idle` as the approved neutral base and
  communicate the latch through the cable, target marker, pull direction, and
  release effects.
- Evidence: `scenarios/player/art.scenario.ts` captures `grappleLatch` from the
  idle atlas and keeps the missing player pose and cable gap explicit.
- Status: open, with candidates. The latch splits into two poses, which is the
  approach the user set on 2026-08-05. Firing the tether reuses
  `cyberpunk-player-shoot`, the single-frame weapon pose already installed.
  Swinging and reeling holds one frame of another sheet rather than playing a
  loop, the way `wallSlide` holds frame 2 of `cyberpunk-player-climb` today;
  `cyberpunk-player-sigil` is the sheet to look at first, since it holds an arm
  out for seven frames. Which frame is chosen is a Milestone 3 call made against
  `scenarios/player/sheetLibrary.scenario.ts`, where each sheet can be stepped
  frame by frame. The cable and hook gap below stays open either way.

## 2026-07-31 — no matching grapple cable or hook

- Category: asset gap
- Trying to do: show terrain reel, enemy pull, heavy-target resistance,
  release, and tether discharge.
- Search terms: `grapple`, `grappling hook`, `hook`, `cable`, `rope`, `chain`,
  and `tether`.
- What happened: the catalog has no cable and hook that match the selected
  cyberpunk set.
- Workaround: use a renderer line, geometric hook marker, directional pulse,
  and latch effects.
- Evidence: `scenarios/player/art.scenario.ts` records the player latch state
  without drawing a cable or hook. A later grapple proof must test the approved
  renderer-line treatment.
- Status: confirmed as an asset gap; grapple implementation remains later work.

## 2026-07-31 — cyberpunk enemy family lacks grounded roles

- Category: asset gap
- Trying to do: cover light, medium, heavy, directional-shield,
  evasion/counter, ranged, and airborne machines.
- Search terms: `cyberpunk enemy`, `robot`, `machine`, `drone`, `turret`,
  `vehicle`, and `ground enemy`.
- What happened: the Luis Zuno family provides a drone, turret, vehicles, and
  effects, but not a complete side-view enemy roster.
- Workaround: create role silhouettes from same-author sprite composites,
  child parts, palettes, lights, and state effects.
- Status: planned proof.

## 2026-07-31 — no matching industrial terrain sheet

- Category: asset gap
- Trying to do: build Transit Ward, Floodworks, and Broadcast Spire geometry
  around the Luis Zuno character set.
- Search terms: `cyberpunk tileset`, `industrial terrain`,
  `factory platformer`, `sewer platformer`, and `sci-fi tiles`.
- What happened: the catalog has no Luis Zuno side-view terrain family.
- Workaround: use a restricted subset of Pixel Frog's
  `tiles-platformer-terrain-mixed` with area palettes, lighting, and strict
  contrast rules.
- Evidence: `scenarios/world/terrainRoom.scenario.ts` renders the approved
  subset — 30 of 242 tiles, recorded in `ASSET-PLAN.md` "Terrain and
  traversal props" — treated, behind the three parallax layers, with the
  installed player in frame.
- Status: technically workable — the subset renders, carries collision, and
  takes the amber treatment. The two-author cohesion judgment and possible
  reselection stay with the human check; the surviving subset has no themed
  variety across the three areas, so area distinction leans on treatment and
  props.

## 2026-07-31 — no coherent safe-area NPC family

- Category: asset gap
- Trying to do: show non-hostile shop and information characters in the safe
  area.
- Search terms: `cyberpunk npc`, `shopkeeper`, `merchant`, `mechanic`, and
  `side-view civilian`.
- What happened: no catalog family matches the selected player and world.
- Workaround: test one stationary same-author composite in each safe area,
  with distinct props, neutral outline, one shop state, and one dialogue
  state. A terminal-only fallback is a scope cut that requires user approval.
- Status: planned proof.

## 2026-07-31 — no three-boss cyberpunk machine family

- Category: asset gap
- Trying to do: give Pursuit Engine, Pump Warden, and Command Chassis distinct
  readable bodies and grapple points.
- Search terms: `cyberpunk boss`, `robot boss`, `machine boss`, `large mech`,
  and `vehicle boss`.
- What happened: the catalog does not contain three matching large machine
  boss sheets.
- Workaround: compose bosses from Luis Zuno vehicles, drones, turrets, and
  explosions, with game-drawn structural backing and effects.
- Status: planned proof; one boss silhouette is the gate.

## 2026-07-31 — no complete metroidvania map-marker set

- Category: asset gap
- Trying to do: mark the player, checkpoints, transit stations, objectives,
  observed gates, exits, and shops on the full map and minimap.
- Search terms: `map icon`, `checkpoint icon`, `objective marker`,
  `fast travel`, and `metroidvania map`.
- What happened: the catalog has no coherent marker family covering the
  required states.
- Workaround: draw a minimal internal vector marker set inside the selected
  Kenney panels.
- Status: planned proof.

## 2026-07-31 — one street set does not cover three nine-section area ecosystems

- Category: asset gap
- Trying to do: give Transit Ward, Floodworks, and Broadcast Spire distinct
  cyberpunk backgrounds and three recognizable internal landmark families per
  area.
- Search terms: `sewer background platformer`,
  `factory background platformer`, `industrial props pipes platformer`,
  `office background cyberpunk`, and `cyberpunk tileset platformer`.
- What happened: the Luis Zuno source covers three depths of one street scene,
  not separate area families.
- Workaround: prove the shared layers behind area-specific game-drawn
  pipe/tank backing, water masks, structural masks, antenna shapes, and signal
  bands. Each area proof must show all three of its internal landmark families
  before the related sections are produced.
- Status: planned proof; three-area coverage remains partial.

## 2026-07-31 — no distinct Transit and Floodworks ambience-bed pair

- Category: asset gap
- Trying to do: maintain environmental identity after the non-loopable Transit
  and Floodworks music cues end.
- Search terms: `transit ambience`, `rail station ambience`,
  `factory ambience loop`, `sewer ambience loop`, and
  `water machinery ambience`.
- What happened: no coherent same-family loop pair covers both areas. The
  selected Kronbits drone is the only loopable bed in that family.
- Workaround: share the drone quietly, layer catalog world one-shots, leave
  deliberate quiet space, and run ten-minute listening proofs.
- Status: planned proof.

## 2026-07-31 — input prompt atlas lacks named binding indices

- Category: asset gap
- Trying to do: show every remappable keyboard and standard-gamepad binding.
- Search terms: `input prompts`, `keyboard keys`, `gamepad buttons`,
  `controller icons`, and `keybinding menu`.
- What happened: `tiles-input-prompts-pixel` has 816 numeric tiles, but its
  metadata does not map binding names or controller families to tile indices.
- Workaround: create and verify a checked binding-to-tile index during the
  asset proof; show text for any missing glyph.
- Status: planned proof.

## 2026-07-31 — gamepad polling releases synthetic pad buttons every frame

- Category: infra/test gap
- Trying to do: hold a synthetic gamepad button across stepped frames from a
  browser test, using `Inspector.input.gamepadButton(code, true)`.
- What happened: the button is held for the rest of the current frame only.
  `InputPollSystem` runs `InputManager._pollGamepads()` in `EarlyUpdate` at
  priority -100, which reconciles every held pad code against
  `navigator.getGamepads()`. With no physical pad attached, the injected code
  is not pressed on any live pad, so reconciliation emits its key-up and drops
  it before gameplay systems read input.
- Evidence: `../yage/packages/input/src/InputManager.ts`
  (`fireGamepadButton`, `reconcileButtonStateAcrossPads`) and
  `../yage/packages/input/src/InputPollSystem.ts`. Synthetic axes take the
  opposite path: `fireGamepadAxis` writes `syntheticAxisState`, which polling
  never clears, so stick and trigger deflections do survive. The engine's own
  unit tests assert `isPressed` immediately after injection without running a
  frame, so no existing test covers the polled case. The behavior is pinned by
  `tests/browser/inspector-driver.spec.ts`.
- Workaround: the scratch entry publishes `window.__scratch__.input`, and
  browser tests call `setPollingEnabled(false)` before injecting pad buttons.
  The production entry publishes no such handle, so a production-entry gamepad
  probe has no supported path today.
- Status: worked around for scratch probes. Candidate engine fix: hold
  synthetic button state separately from real-pad state, mirroring
  `syntheticAxisState`.
- Refinement: only held state is lost. The press edge is delivered before the
  next poll reconciles the code away, so `isJustPressed` reports it and a
  consumer reading edges needs no workaround. `scenarios/menu/focus.scenario.ts`
  drives a whole focus screen — four directions, confirm, cancel, and a tab
  change — through injected pad codes with polling left on, and separately
  asserts that the same code reports `isPressed === false` a few frames later.
  A consumer that repeats while a direction is held is the case that still
  needs polling switched off.

## 2026-08-01 — Tiled map, layer, and tileset custom properties are dropped

- Category: missing primitive
- Trying to do: read one section's identity — section ID, area ID, display
  name, and full-map atlas coordinates — from the map it is authored in.
- What happened: Tiled stores custom properties on the map, on each layer, on
  each tileset, and on each object. The engine's types carry `properties` on
  objects only, so `TilemapData` exposes no map-level or layer-level property.
  Identity has to live on a marker object instead of on the map itself.
- Evidence: `../yage/packages/tilemap/src/tiled/types.ts` declares
  `properties?: TileObjectProperty[]` on the six object shapes and on no other
  interface. `TiledMapData`, `TileLayer`, `ObjectGroup`, and `TilesetData`
  have no property field, and `../yage/packages/tilemap/src/types.ts` repeats
  the omission for `TilemapData`, `TileLayerData`, and `ObjectLayerData`.
- Workaround: the convention requires one `metadata` object layer holding
  exactly one point object of class `section`, and the parser rejects a map
  that has none or several. Layer visibility toggles that would naturally use
  a layer property have no home yet.
- Status: worked around. Candidate engine fix: carry `properties` through
  `toTilemapData` for the map, each layer, and each tileset, and expose the
  same `getProperty` helper for them.

## 2026-08-01 — `toTilemapData` discards unsupported map forms without a signal

- Category: API friction
- Trying to do: reject an isometric, infinite, chunked, group-layered, or
  TSX-referencing section map with a diagnostic naming the file and the
  supported replacement.
- What happened: none of those forms is detectable from the converted data.
  `TilemapData` keeps width, height, tile size, tile layers, and object layers
  and nothing else, so `orientation`, `infinite`, `encoding`, and the tileset
  references are gone. A group layer is worse than absent: the conversion loop
  matches only `tilelayer` and `objectgroup`, so a group and every layer nested
  inside it is skipped silently and the map parses as if those layers were
  never authored.
- Evidence: `../yage/packages/tilemap/src/tiled/parseTiledMap.ts`
  (`toTilemapData`) and `../yage/packages/tilemap/src/types.ts`
  (`TilemapData`).
- Workaround: `src/features/world/sections/SectionMapParser.ts` runs a preflight over
  the raw `JSON.parse` result before conversion, and only then calls
  `toTilemapData`. Every game that accepts authored maps has to repeat it.
- Status: worked around. Candidate engine fix: return diagnostics from the
  conversion, or expose a validator that reports the unsupported forms.

## 2026-08-01 — extracted objects lose the layer they came from

- Category: API friction
- Trying to do: read authored records keyed by layer *and* class, because the
  section convention uses layer name for the runtime owner and class for the
  record kind.
- What happened: `extractObjects()` and `TilemapComponent.getObjects()` return
  `Record<string, TileObject[]>` keyed by `obj.class ?? obj.type ?? obj.name`.
  Passing a layer name filters the input but does not appear in the result, so
  two layers holding the same class merge into one bucket and a classless
  object is keyed by its name.
- Evidence: `../yage/packages/tilemap/src/tiled/parseTiledMap.ts`
  (`extractObjects`) and `../yage/packages/tilemap/src/TilemapComponent.ts`
  (`getObjects`).
- Workaround: walk `toTilemapData(raw).objectLayers` and match on `layer.name`
  directly. `getProperty` still works, because it takes anything with a
  `properties` array.
- Status: worked around. Candidate engine fix: key the result by layer, or add
  a variant that returns `{ layer, class, objects }` records.

## 2026-08-01 — an overlap query reports the surfaces a resting body touches

- Category: recipe gap
- Trying to do: decide whether the standing collider fits before restoring it
  at the end of a slide, using a static overlap query at the body's position.
- What happened: `PhysicsWorld.queryShape` reports a collider the query box
  merely touches, and `excludeEntity` only removes the querying body's own
  colliders. A query box the exact size of the standing shape therefore reports
  the floor the body rests on and any wall its side is flush against, so the
  restoration is refused in the open. The query has no contact tolerance and no
  "ignore touching" option.
- Evidence: measured in `tests/browser/stance-probe.spec.ts`. With the
  horizontal inset in `src/features/player/PlayerStance.ts` set to zero, the
  wall case fails: standing is refused beside a wall the body rests flush
  against, with clear headroom above it. `../yage/packages/physics/src/PhysicsWorld.ts`
  (`queryShape`) passes the shape straight to Rapier's
  `intersectionsWithShape` and leaves its filter flags undefined, which also
  means sensor colliders are counted — `EXCLUDE_SENSORS` is opt-in in
  `@dimforge/rapier2d`'s `pipeline/query_pipeline.d.ts`. The probe defines no
  sensors, so it does not exercise that half.
- Workaround: query only the volume the taller shape would newly occupy, inset
  horizontally by a pixel, and filter to blocking layers with `filterGroups`.
- Status: worked around, and every clearance test in the game has to repeat
  the same three corrections. Candidate engine fix: a documented clearance
  recipe, or a query option that ignores contacts below a depth threshold.

## 2026-08-01 — a kinematic body is drawn a step ahead of everything around it

- Category: API friction
- Trying to do: carry a player on a moving platform without the player
  appearing to shift along the platform when it turns around.
- What happened: a kinematic body is driven by writing its Transform, and
  `PhysicsSystem.preStep` reads that Transform to pick the pose the coming step
  moves the body to. The Transform therefore holds a pose one fixed step ahead
  of where the body stands, and the renderer draws from the Transform.
  `PhysicsInterpolationSystem` blends the poses of the last two steps for
  dynamic bodies only, so a dynamic body is drawn between one and two steps
  behind its kinematic platform. The gap reverses sign with the platform's
  direction, so a rider standing perfectly still on a platform jumps along it
  at every turn.
- Evidence: measured in the moving support probe with the clock frozen. Drawn
  distance between the rider and the platform held at -7.999 px while the
  platform travelled one way, then -3.999 px after it turned, a 4 px jump with
  the physics distance constant at -6 px to within 0.02 px. The two sources are
  `../yage/packages/physics/src/PhysicsSystem.ts` (`preStep` calls
  `setNextKinematicTranslation(transform.worldPosition)`) and
  `../yage/packages/physics/src/PhysicsInterpolationSystem.ts`, which skips
  every body whose `type !== "dynamic"`. `tests/browser/moving-support.spec.ts`
  covers it: without the correction the drawn distance spreads 4.0 px across a
  reversal, with it 0.001 px.
- Workaround: `src/features/world/MovingPlatformVisual.ts`. The platform's
  drawing lives on a child entity, and the child is placed each frame at the
  platform's own two-step blend using the interpolation factor from
  `PhysicsWorldManager.getContext(scene).alphaRef`. Every kinematic body with a
  visual needs the same treatment.
- Status: fixed and adopted, in `@yagejs/physics` 0.10.1 through yage PR #235.
  Kinematic bodies are interpolated from the same two-step blend as dynamic
  ones, and the `Transform` stays the movement input because a game write is
  captured into an internal target before the blend overwrites it. The
  game-side workaround is gone: a platform draws from its own entity, and the
  drawn distance between rider and platform holds to 0.25px across reversals.

## 2026-08-01 — a crushed body is pushed through static geometry

- Category: missing primitive
- Trying to do: find out what happens when a rising platform presses its rider
  into a static ceiling, so a crush policy can be chosen with evidence.
- What happened: the rider penetrates both surfaces and is driven through the
  ceiling. The solver has a position-based kinematic body on one side and a
  static body on the other, and neither yields, so the dynamic body between
  them keeps the platform's motion and passes out the far side of the ceiling.
  It comes back down when the platform reverses, and the support reference
  survives the squeeze, but nothing reports the crush and no contact stops it.
- Evidence: measured in `tests/browser/moving-support.spec.ts` at the crush
  station. The ceiling's underside is at y=330 and its top at y=314. Forty
  steps after the platform starts rising, the rider's head is at y=306.9, above
  the ceiling entirely: the three bodies overlap.
- Workaround: none. The probe records the behaviour rather than choosing a
  policy.
- Status: open. A crush policy is a game decision, but it needs an engine
  signal to build on: there is no contact-force or penetration-depth report for
  a persisting contact, and `CollisionEvent` carries `penetrationDepth` only on
  the started edge. A game that has to kill or stop a crushed body must
  query for it every step.

## 2026-08-01 — a followed body shakes against its camera every frame

- Category: API friction
- Trying to do: watch a rider carried by a moving platform without the whole
  picture twitching.
- What happened: `CameraFollow` is an ordinary component, so it reads its
  target during the update phase. `PhysicsInterpolationSystem` writes the pose
  a dynamic body is drawn at in late update, after it. The camera therefore
  locks onto the pose the last physics step produced while the body is drawn
  at the pose before it. What the camera reads therefore depends on whether a
  fixed step ran in that frame: on a frame with one it reads the new pose and
  leads the drawing by a whole step, and on a frame without one it reads what
  the interpolation system left behind and leads by nothing. The gap alternates
  between one step of travel and zero, so the followed body slides back and
  forth against a camera configured to hold it perfectly still. Nothing in the
  follow API hints at it, and `smoothing: 1` reads as "no lag".
- Evidence: measured in `tests/browser/moving-support.spec.ts` with 10ms frames
  against the 16.67ms fixed step, so some frames run a physics step and others
  run none, as they do at any real frame rate. Following the rider's `Transform`
  swings the drawn distance
  between rider and camera by 1.0px at 60px/s, one full step of travel; a
  player running four times faster would swing four times as far. Following the
  drawn pose instead holds it to 0.001px. The two sources are
  `../yage/packages/renderer/src/CameraFollow.ts` (`update(dt)`) and
  `../yage/packages/physics/src/PhysicsInterpolationSystem.ts` (`Phase.LateUpdate`,
  priority 100).
- Workaround: `src/features/presentation/DrawnPoseTarget.ts`. `CameraFollow.target`
  accepts any object with a `position`, so the game hands it one whose getter
  blends the poses of the last two fixed steps by the same interpolation
  factor. It has to keep its own copy of those two poses, because
  `RigidBodyComponent` exposes them only as underscore-prefixed internals.
- Status: fixed and adopted, in `@yagejs/physics` 0.10.1 through yage PR #233.
  Interpolation runs ahead of every component update, so a component reads the
  pose that is drawn. The game-side workaround is gone: the camera subject
  reads its `Transform` directly, and `tests/browser/camera.spec.ts` still
  holds the drawn distance between subject and camera to 0.25px over uneven
  frames.

## 2026-08-01 — physics interpolation never interpolates

- Category: missing primitive
- Trying to do: move a body smoothly at a frame rate that is not the fixed
  timestep, which is every display that is not exactly 60Hz and every dropped
  frame on one that is.
- What happened: `PhysicsInterpolationSystem` blends the poses of the last two
  fixed steps by `ScenePhysicsContext.alphaRef`, and that value is always zero,
  so the blend always returns the earlier pose. Every dynamic body is therefore
  drawn snapped to fixed-step positions and one step stale. Motion advances in
  whole steps: at 120Hz every second frame draws no movement at all, and at
  100Hz the pattern is two frames still, one frame a full step.
- Evidence: `GameLoop` keeps the real leftover and exposes it as
  `interpolationAlpha`, and a search across `core`, `physics`, `renderer`, and
  `debug` finds no reader of that property — only its definition in
  `../yage/packages/core/src/GameLoop.ts`. `PhysicsSystem.stepScene` instead
  keeps a second accumulator, adds the fixed timestep it was handed, steps once,
  and leaves exactly zero behind, then writes `alphaRef = accumulator / dt`.
  Measured live over 12 frames of 10ms with the clock frozen: `alphaRef` read
  0 every frame while `GameLoop.interpolationAlpha` cycled 0.598, 0.198, 0.798,
  0.398, 0.998. The camera following a body advanced 1px, 0px, 1px, 0px instead
  of 0.6px a frame.
- Workaround: none for a body the engine draws. Game code cannot write a
  Transform after `PhysicsInterpolationSystem`, which runs in late update, and
  only a plugin can register a system in a phase. `MovingPlatformVisual` and
  `DrawnPoseTarget` read the same `alphaRef`, so they stay consistent with the
  engine's own drawing at zero and would follow a real value if one arrived.
- Status: fixed and adopted. Reported as yage issue #230, fixed by yage PR
  #233, released in `@yagejs/physics` 0.10.1, which this game now depends on.
  `PhysicsInterpolationSystem` computes each scene's alpha from the game loop's
  leftover and runs at `Phase.Update` priority -100, ahead of every component
  update. The numbers above stand as the record of 0.10.0.

## 2026-08-03 — a collider's offset cannot change with its shape

- Category: missing primitive
- Trying to do: shrink the player's body box for a slide and grow it back,
  keeping the feet planted. The body is foot-anchored: the Transform is at the
  feet and the box rises from it through `ColliderConfig.offset`, so a box half
  as tall needs half the offset.
- What happened: `ColliderComponent.setShape` replaces the shape and nothing
  else. `PhysicsWorld.setColliderShape` calls Rapier's `collider.setShape` and
  `setRotationWrtParent`, and never touches the collider's translation relative
  to its body. `ColliderComponent` exposes `setSensor`, `setShape`, and
  `setContactFilter`; `offset` is construction-only on `ColliderConfig`. A
  crouch done with `setShape` alone therefore keeps the old offset, so the
  shorter box stays centred where the taller one was and the body sinks or
  floats by half the height difference.
- Evidence: `node_modules/@yagejs/physics/dist/index.js`, `setColliderShape`,
  and the `ColliderComponent` surface in
  `node_modules/@yagejs/physics/dist/api-ksqGw1ij.d.cts`. The release note for
  yage PR #228 names a crouch as the use case, which is the case the missing
  offset blocks.
- Workaround: `src/features/player/PlayerStance.ts` replaces the whole
  `ColliderComponent`, which is why it owns a subscription registry that
  survives the replacement.
- Status: open. Candidate engine fix: an offset argument on `setShape`, or a
  `setOffset` beside it, so a body-relative box can move as it resizes.

## 2026-08-01 — `CameraFollow` cannot preserve a computed target across save and restore

- Category: API friction
- Trying to do: keep a camera pointed at a game-computed focus across a save
  and a load. `CameraDirector` computes look-ahead and a vertical anchor and
  gives the camera the result.
- What happened: `CameraFollow.target` accepts any `{ position: Vec2Like }`,
  but its two snapshot branches preserve no computation. A target without an
  `entity` back-reference is saved as `{ kind: "point", position }`, which
  freezes its current value. A target with an `entity` back-reference — such
  as a component — records `{ kind: "entity-transform", entityId }`;
  `afterRestore` resolves that ID and sets the target to that entity's
  `Transform`, replacing the computation with the entity's pose. A computed
  focus therefore either comes back frozen or follows the entity itself.
- Evidence: `../yage/packages/renderer/src/CameraFollow.ts`, methods
  `serialize`, `extractTargetEntityId`, and `afterRestore`. The
  `{ position: Vec2Like }` signature accepts any object with a position, and
  only an entity's own `Transform` is restored as a moving target.
- Workaround: make the computed focus a real `Transform` on the camera's own
  scene-root entity. `CameraDirector` writes the focus into that `Transform`
  every frame and `GameCamera` follows it, so `CameraFollow` records the entity
  ID and restores the same object; the director keeps updating it after a load.
  Nothing has to be re-applied, and the focus is visible through
  `Inspector.getEntityPosition`. The entity has to stay a scene root, because
  `CameraFollow` reads `position`, which is parent-relative.
- Status: worked around, and the workaround is the shape the engine expects.
  Candidate engine fix: record a point target for anything that is not a
  `Transform` so the camera at least resumes where it was, or say in the type
  that the target must be an entity's `Transform`.

## 2026-08-01 — components have no update order of their own

- Category: missing primitive
- Trying to do: run a game component before an engine component that reads what
  it wrote, in the same frame. `CameraDirector` writes a focus point that
  `CameraFollow` copies, so a director running second would hand the camera the
  previous frame's focus and reintroduce the shake it exists to remove.
- What happened: nothing in the component API expresses order.
  `ComponentUpdateSystem` iterates `scene.getEntities()` and then
  `entity.getAll()`, so the order is entity spawn order first and component add
  order second. `Component` does carry a priority, but it is
  `static restorePriority`, which orders only how components are re-added
  during a snapshot restore — easy to mistake for the update order it is not.
  A component also cannot choose a phase: the engine runs `EarlyUpdate`,
  `FixedUpdate`, `Update`, `LateUpdate`, `Render`, and `EndOfFrame`, but a
  component gets exactly two hooks, and both are called by
  `ComponentUpdateSystem` at `Update` and `FixedUpdate`, priority 1000.
- Evidence: `../yage/packages/core/src/ComponentUpdateSystem.ts` for the two
  loops and the priority, `../yage/packages/core/src/Component.ts` for the two
  hooks and `restorePriority`, and `../yage/packages/core/src/types.ts` for the
  phase list. `CameraEntity.setup` depends on the same rule internally: its own
  doc comments say the bounds clamp works because `CameraBoundsComponent` is
  added after `CameraFollow`.
- Two escapes exist, both used elsewhere in the engine:
  1. Control add order on one entity. `GameCamera` extends `CameraEntity` and
     adds `CameraDirector` before `super.setup()`, so the director precedes
     every engine camera component. Order becomes local and explicit instead of
     a rule about which entity was spawned first.
  2. Register a `System`. `SystemScheduler` is in the engine context under
     `SystemSchedulerKey` and its `add` is public, so a game can run code at
     any phase and priority. A system at `Update` below priority 1000 runs
     before every component update. yage PR #233 does exactly this, moving
     interpolation to `Update` at -100.
- Workaround: escape 1, proved by measurement. `tests/browser/camera.spec.ts`
  fails by 3.7px — one fixed step of travel at the probe's speed — when the
  director is added after `super.setup()`.
- Status: open. Candidate engine fix: a `priority` on `Component` honoured by
  both update passes, so an ordering requirement can be declared where it
  applies rather than encoded in construction order.

## 2026-08-03 — the scenario lab pins every frame to 1/60s

- Category: infra/test gap
- Trying to do: measure what a player sees at a frame rate that does not divide
  into the fixed step. A reader that samples the wrong pose — a camera, a
  visual, a rule reading a position in the wrong phase — is invisible when
  every frame carries exactly one physics step, because the error is then the
  same every frame and a spread measurement cannot see it. Two of this game's
  scenarios exist to catch exactly that.
- What happened: `@yagejs-tools/lab` issues frames at a fixed delta.
  `LabClock.STEP_MS` is `1000 / 60`, the clock's own doc says the delta stays
  pinned at it whatever the speed control does, and `DriveContext.step(frames)`
  takes no delta.
- Evidence: `node_modules/@yagejs-tools/lab/dist/runner.js`, `LabClock`, and
  the `step` signature in `node_modules/@yagejs-tools/lab/dist/index.d.ts`.
  The engine already supports what is needed:
  `Inspector.time.stepAsync(frames, opts?)` takes `{ dtMs }` per call
  (`node_modules/@yagejs/core/dist/index.js`, `stepAsync`). The lab's `step`
  simply does not forward it.
- Workaround: a driven run issues its own frames through `Inspector.time`
  rather than through the lab clock, so a drive can set the delta itself and
  restore it afterwards. `scenarios/world/movingSupport.scenario.ts` and
  `scenarios/presentation/camera.scenario.ts` do that around their measured
  windows. It works, but it reaches past the lab's own API into the Inspector.
- Status: closed by yage PR #254, in `@yagejs-tools/lab` 0.1.1:
  `step(frames, { dtMs })` and `until(predicate, { maxFrames, dtMs })` set what
  one frame simulates for that call. `scenarios/world/movingSupport.scenario.ts`
  and `scenarios/presentation/camera.scenario.ts` still drive their measured
  windows through `Inspector.time`; moving them onto the lab's own option is
  work for whoever next touches those two situations.

## 2026-08-03 — a driven run has no real-time playback

- Category: infra/test gap
- Trying to do: watch a scenario play at the speed a player would see it. The
  Run button is the obvious way to make something happen, especially in a
  scenario whose situation is otherwise static until something drives it.
- What happened: a run issues its frames as fast as macrotasks allow.
  `DriveContext.step` calls `Inspector.time.stepAsync`, which advances one
  frame per macrotask, so a 300-frame drive finishes in a fraction of a second.
  Nothing is skipped — every frame is simulated — but what a person sees is the
  end state rather than the motion, which reads as a teleport.
- Evidence: `stepAsync` in `node_modules/@yagejs/core/dist/index.js` steps one
  frame and yields a macrotask per iteration; `runDrive` in
  `node_modules/@yagejs-tools/lab/dist/runner.js` calls it with no pacing. The
  panel's own clock is the paced one, at a fixed 1/60s.
- Workaround: give the scenario keyboard control, so the paced clock plus a
  person's hands is the way to watch, and leave the drive as the gate. Every
  scenario here binds the game's own `left`/`right`/`jump` actions plus a few
  of its own, and the keys call the same methods the drive calls.
- Status: closed by yage PR #254, in `@yagejs-tools/lab` 0.1.1: a **real time**
  checkbox beside **Run** plays one engine frame per animation frame, and
  `yage-lab test` never paces. The keyboard control every scenario binds stays,
  because driving by hand is still how a feel question gets answered.

## 2026-08-04 — the tilemap loader skips embedded tilesets

- Category: API friction
- Trying to do: render the plain Transit proof map through
  `TilemapComponent` with the terrain tileset embedded in the map JSON.
- What happened: the loader reaches `resolveTileTexture` with no tileset data,
  so GID 1 fails at frame 0 with `Could not resolve texture for tile GID 1 in
  tileset "undefined"`. The loader assigns `tilesetRef.data` only inside its
  `if (tilesetRef.source)` branch, then skips a source-less reference when
  `tilesetRef.data` is absent.
- Evidence: `node_modules/@yagejs/tilemap/dist/index.js:89-98` shows the
  `source` load, the `tilesetRef.data` assignment, and the `continue` for a
  missing tileset. The section preflight
  (`src/features/world/sections/SectionMapParser.ts:227-233`) rejects only
  `.tsx` references and names "an external tileset exported as JSON" as
  acceptable, so the embedded-tileset convention is fixed by the section
  fixtures and docs, not enforced by the parser. Section maps could adopt the
  same external-JSON workaround without a parser change.
- Workaround: `public/assets/transit-proof-room.json` uses the external JSON
  reference `transit-proof-tileset.json`. The proof map is deliberately plain,
  not a section map, so this exception does not change the section convention.
- Status: closed by yage PR #259, in `@yagejs/tilemap` 0.10.2, which resolves an
  embedded tileset. Every map here keeps its external JSON reference, which is
  also the convention the section fixtures are written against.

## 2026-08-04 — TilemapComponent has no tint or effects-host path

- Category: API friction
- Trying to do: apply the Transit treatment to the terrain while leaving the
  player untinted and using the existing parallax sprite tints.
- What happened: setting `world.tilemap.container.tint` passed the scenario's
  property assertion but did not change the rendered terrain. In the failing
  run's captures, a floor sample rect averaged the same RGB
  (`65.6/66.8/82.4`) in the untreated and treated frames, while the parallax
  regions of the same two frames did change — the tint reached the sprites
  but not the tilemap. The filter treatment renders after the fix.
- Evidence: `TilemapComponent` extends core `Component` at
  `node_modules/@yagejs/tilemap/dist/index.d.ts:350`, so it has no renderer
  visual-component `.fx`. The renderer visual-component base exposes `.fx` at
  `node_modules/@yagejs/renderer/dist/index.d.ts:170-175`. The tilemap render
  path also ignores ancestor tint, as shown by the pixel-identical captures.
- Workaround: in the development-only terrain scenario, attach a raw Pixi
  `ColorMatrixFilter` to `tilemap.container.filters`; keep the parallax
  `SpriteComponent` tints and player tint unchanged. The scenario imports
  `pixi.js` without declaring it in `package.json`, resolving through the
  renderer's transitive install: a declared copy could diverge from the
  renderer's own Pixi instance, and the import lives only in development
  code that the production-isolation test keeps out of the bundle.
- Cost of the workaround, beyond the extra code: raw Pixi comes with its own
  documentation errors, which `@yagejs/effects` presets would have hidden.
  `ColorMatrixFilter.contrast(amount, multiply)` is documented as taking 0 to 1
  with 0.5 as normal contrast, and its implementation uses `amount + 1` as the
  multiplier, so 0 is the untreated image and 0.5 is a 50% boost. A treatment
  written from the doc starts at half contrast.
- Status: closed in `@yagejs/tilemap` 0.10.2. `TilemapComponent` extends the
  renderer's `VisualComponent`, so it takes `tint`, `alpha`, `blendMode`,
  `visible` and `interactive`, and exposes `.fx` and `setMask`. The Transit
  Ward and Floodworks treatments are a `tint` assignment, and neither scenario
  imports `ColorMatrixFilter` any more; both still import `Assets` from
  `pixi.js` for texture loading. The Spire keeps a raw filter for a different
  reason — see the contrast entry below.

## 2026-08-04 — a camera layer binding cannot separate its axes

- Category: API friction
- Trying to do: give the three bottom-aligned city layers horizontal parallax
  while their bottoms stay on the floor line, using `CameraBinding`'s
  documented parallax recipe.
- What happened: `translateRatio` is one number for both axes. Any ratio
  below 1 drifts the layer vertically along with the camera's y, so the
  city's bottom edge slid below the floor line whenever the camera sat away
  from the world origin.
- Evidence: `CameraBinding` in `node_modules/@yagejs/renderer/dist/index.d.ts`
  (the `translateRatio?: number` field and its parallax recipe comment). The
  drift was visible in the terrain-room captures as an orange band of skyline
  below the foundation.
- Workaround: leave every layer at full follow and move the layer sprites
  horizontally each frame — `ParallaxDriftStandIn` in
  `scenarios/world/terrainRoom.scenario.ts` offsets each sprite by the
  camera's travel times the layer's remaining depth, and never touches y.
- Status: worked around. Candidate engine fix: accept a per-axis form for
  `translateRatio` (a number or `{ x, y }`), which is the common side-scroller
  need.

## 2026-08-04 — lab captures render content bounds, not the game's view

- Category: infra/test gap
- Trying to do: read gameplay framing from `yage-lab test --screenshots`
  captures, with the camera at zoom 2 over an 800x600 virtual view.
- What happened: a capture's size tracks the scene's drawn content extents
  times zoom, not any viewport — widening a backdrop rectangle from 1536 to
  5120 world pixels grew the capture from 3332 to exactly 10240 pixels wide.
  Past the GPU framebuffer limit the capture comes back blank white with no
  error. The panel canvas likewise tracks the page rather than the game's
  virtual resolution, while `CameraBounds` clamping still uses the virtual
  viewport, so what a capture frames is not what the game would show.
- Evidence: capture sizes across the terrain-room runs (1536, 3332, 6656,
  and 10240 pixels wide) each match the scenario's content extents at the
  time; the 10240-wide run produced blank PNGs while all 17 scenarios
  reported passing.
- Workaround: treat the backdrop rectangle as the deliberate capture frame,
  and keep content extents times zoom under the framebuffer limit. Judge
  true gameplay framing in the panel, not in captures.
- Status: worked around. Candidate fix: a lab option to capture the camera's
  view at the game's virtual resolution, and a warning when content extents
  times zoom exceed the framebuffer limit instead of a silent blank capture.

## 2026-08-04 — catalog tilesets carry no tile-role metadata

- Category: asset gap
- Trying to do: author a room from `tiles-platformer-terrain-mixed` using
  each tile as its artwork intends — caps ending runs, repeatable middles
  between them, fixed slabs kept whole, the brick ring kept assembled.
- What happened: the catalog companion records the frame grid but nothing
  about tile roles, and 16px tiles defeat vision-based judgment at natural
  size. The first authored floor repeated caps and edge pieces as fill and
  read as a fence of disconnected blocks.
- Evidence: the first proof-room terrain layer and its captures. The roles
  became reliable only after mechanical analysis: per-border seam
  measurement over the sheet plus 14x zoom crops of each group.
- Workaround: the measured roles are recorded as
  `TERRAIN_COMPOSITION_RULES` in `src/features/world/terrainAssets.ts`, and
  `validateTerrainComposition` holds every placement to them in
  `tests/unit/terrain-composition.test.ts` and the terrain-room drive.
- Status: worked around for this tileset. Candidate fix: per-tileset role
  and adjacency metadata in the asset library's companion files, beside the
  frame grids and nine-slice insets the catalog already measures.

## 2026-08-04 — the terrain author was chosen against a library that has since grown

- Category: asset gap
- Trying to do: pick a side-view terrain set in the same hand as the selected
  Luis Zuno player.
- What happened: the original search found no Luis Zuno terrain sheet, so
  Pixel Frog terrain was selected and the cross-author cohesion risk became
  the asset plan's largest open question — a whole proof, a reject rule, and
  a human check existed to settle it. The library has since gained
  `cyberpunk-city`, `sewers`, `warped-caves`, and `warped-caves-walls`, all
  Luis Zuno, which removes the risk rather than resolving it.
- Evidence: the four files are untracked in `../yage-assets`, and
  `git show HEAD:CATALOG.md` there matches no row for any of them, so the
  original search was correct when it ran.
- Workaround: none needed. `ASSET-PLAN.md` gap 4 is closed and terrain moves
  to the Luis Zuno sets.
- Status: resolved by the library. The lesson is procedural: a selection made
  against a growing catalog needs a re-check before the work it justifies is
  built, not only when it fails. Six sessions of terrain work ran on a
  selection that a one-line search would have overturned.

## 2026-08-04 — a facade tileset needs its walkable tiles pointed out

- Category: asset gap
- Trying to do: identify which `cyberpunk-city` tiles are the surfaces a
  player stands on, so a room of buildings can be authored from them.
- What happened: the sheet is 384 tiles of facade — windows, doors, shutters,
  ladders, pipes, control panels, vents, signage. Border-seam measurement
  found the repeatable wall fill without trouble, but the walkable cap was
  not separable from the decorative props by measurement alone: the scan for
  a lit top edge over a solid body ranked lit windows and monitors above it,
  because a glowing window has exactly that signature. The catalog describes
  the run as an "awning", which reads as decoration rather than as the
  platform surface. Marco named the ids directly.
- Evidence: the cap is `270` left, `271` and `272` repeatable middles, `273`
  right — one tile tall, lit cyan top edge, confirmed by border seams
  (`271`/`272` repeat horizontally at a seam cost of 7 and do not repeat
  vertically, `270` and `273` are open on their outer sides).
- Workaround: record the roles in the game's terrain manifest and hold every
  placement to them, as the Pixel Frog set already does.
- Status: worked around. Candidate fix: a `walkable` or `surface` role in the
  asset library's per-tileset metadata. A tileset's collision-bearing tiles
  are the first thing a game needs and the hardest to infer from pixels,
  since decoration and surfaces share a visual signature.

## 2026-08-04 — a tile-adjacency rule table cannot express a freeform kit

- Category: infra/test gap
- Trying to do: keep a hand-authored room honest by encoding which tiles may
  sit beside which, the way the Pixel Frog proof did.
- What happened: the rules were derived from border-seam measurement, which
  reads a sheet as an autotile set. `cyberpunk-city` is a kit: pillars stretch
  to any height by repeating their middles, dark fill goes anywhere, and a cap
  run is deliberately interrupted where a ladder passes through a balcony.
  When Marco authored two buildings in Tiled, the rules reported 219
  violations, all of them correct authoring.
- Evidence: `tests/unit/room-geometry.test.ts` replaced the rule check. The
  three failures it now covers are the ones that actually occurred in the
  authored map: a rectangle dragged without snapping (width 160.92, height
  16.86), two rectangles left behind after their tiles were deleted, and the
  possibility of a walkable cap with no collision under it.
- Workaround: check that collision agrees with the tiles, and let tile
  placement be the author's judgment.
- Status: resolved by narrowing what is checked. The lesson generalises: for
  a kit sheet, the machine should verify the game-breaking invariant —
  collision against art — not second-guess composition.

## 2026-08-04 — animated props installed as static images

- Category: asset gap
- Trying to do: hang signage on the city facades.
- What happened: two of the six installed props are animated —
  `cyberpunk-banner-neon` (4 frames of 19×48) and `cyberpunk-banner-sushi`
  (3 frames of 36×13) — and both were drawn with a `SpriteComponent` over the
  whole PNG. That renders the entire sheet, so each sign appeared as its
  frames laid out side by side rather than one flickering sign. The manifest
  also recorded the image's size as the prop's size.
- Evidence: the catalog names the frame counts, and the companion atlas beside
  each PNG carries the frames and the animation. Both were available and both
  were skipped: the props were installed in a shell loop whose output was
  piped through `head -2`, which cut the CLI's own line — "wrote
  cyberpunk-banner-neon.json — load it, don't slice frames by hand".
- Workaround: `CITY_PROPS` is a discriminated union of static and animated
  props; an animated one carries its frame count, loads through
  `spritesheet()`, and draws with an `AnimatedSpriteComponent`. The scenario
  drive asserts each recorded frame count against the loaded atlas, so a prop
  that is secretly animated fails rather than rendering wrong.
- Status: closed by yage PR #259, in `@yagejs/tilemap` 0.10.2: the tilemap
  plays equally-spaced, equal-duration tile animations on scene time, so an
  animated tile no longer has to be lifted out as a sprite. The prop union in
  `src/features/world/terrainAssets.ts` stays as it is, because the signage is
  placed as sprites rather than as tiles. The lesson for an agent installing
  assets in bulk still holds: the per-asset install output is the only place
  the frame question is raised, so truncating it loses the one signal that a
  PNG is a sheet. Read the companion atlas for every installed image.

## 2026-08-04 — the tilemap loader silently drops Tiled's draw offsets

- Category: API friction
- Trying to do: place a walkway whose art does not fill its tile cell, and
  reach for the Tiled fields that exist for exactly that.
- What happened: the loader reads neither a tileset's `tileoffset` nor a
  layer's `offsetx`/`offsety`. Tiled draws both, so a map that uses either
  looks right in the editor and renders wrong in the game, with nothing
  reported at load or at draw. Only the tile layer entity's own `Transform`
  moves what is drawn.
- Evidence: `createTilemapLayers` in `@yagejs/tilemap` places every tile at
  `x * map.tilewidth, y * map.tileheight`, and neither field appears anywhere
  in the package.
- Workaround: none needed here. The Floodworks room uses no offset — its
  walkway units are placed so the walking line falls on a tile boundary, which
  is what the art is drawn for. The gap still waits for the first section map
  whose author uses either field.
- Status: closed by yage PR #259, in `@yagejs/tilemap` 0.10.2, which applies a
  tileset's `tileoffset` and a layer's `offsetx`/`offsety` when placing tiles.
  The same release honours per-layer `visible` and `opacity`, loads embedded
  tilesets, and renders flipped and rotated tiles, and `validateTiledMap()`
  reports the forms it still cannot render. No map here uses an offset, so
  nothing changed in this game's rooms.

## 2026-08-04 — a multiply tint cannot cool art that has no blue in it

- Category: recipe gap
- Trying to do: give Floodworks the cold treatment the asset plan calls for,
  the way the Transit Ward proof gives its city a warm one — a `tint` on the
  background sprites and a `ColorMatrixFilter.tint` on the tilemap.
- What happened: nothing visibly changed. The `sewers` walls are pure olive:
  the wall body measures `#202000` and the pipes `#1d1d00`, both with a blue
  channel of zero. A tint multiplies, so it can only take colour away — no
  multiplier produces blue from zero. The treated room came out as a darker
  version of the untreated one at every strength tried.
- Evidence: sampling the same pixels untreated and treated showed the wall
  moving `#202000` → `#0f1500`, darker and still with no blue.
- Workaround: `colorize` from `@yagejs/effects` maps luminance onto a colour
  instead of multiplying, so it can turn a zero-blue pixel cold. It attaches
  through `VisualComponent.fx`, which every sprite has. The room grades it by
  depth: the far vista takes it at 0.95 strength, the near wall at 0.28, so
  the pipes and lamps stay legible.
- Status: worked around. The general lesson: `tint` is the obvious tool and it
  silently cannot recolour single-hue art, while the primitive that can is one
  the mechanic index does not route a treatment task to.

## 2026-08-04 — a scenario key handler that reads game state cancels its own drive

- Category: infra/test gap
- Trying to do: let a person drive a room in the panel with the same methods
  the headless drive calls.
- What happened: the room's key component compared the slide key against the
  mover's current intent rather than against the key's own last state. A
  keyboard resting at zero therefore looked like a release every frame the
  drive held the intent, so the drive's request was cancelled one frame later
  and the body stood back up. The Transit Ward room carried the same handler
  and passed anyway: its slide only ever happens under a balcony, where
  standing is refused for lack of headroom, so the cancellation had no
  visible effect. Floodworks starts its slide in the open and failed at once.
- Evidence: the Floodworks drive asserted `slide` one frame after asking for
  it and got `standing`, at a body position 105px short of the low catwalk.
- Workaround: `scenarios/world/RoomKeys.ts` holds one last-state field per
  key and writes only on a change, which is the rule the scenario contract
  already states. Both rooms use it.
- Status: resolved. The lesson generalises: a scenario input path must
  reconcile against its own previous input, never against the state it drives,
  or a passing assertion can be measuring the wrong mechanism.

## 2026-08-04 — a tileset can be a handful of fixed units rather than a kit

- Category: asset gap
- Trying to do: compose a Floodworks room from the `sewers` tileset the way
  the Transit Ward room is composed from `cyberpunk-city`.
- What happened: the catalog records 85 tiles of 16×16, which reads like a
  terrain set. Only 36 carry art, and they form four fixed 3×3 assemblies: one
  water block and a raised walkway in three variants — an opening run, a
  repeating middle with a support post, and an end that breaks off on torn
  metal. There are no wall, ceiling, edge, or prop tiles, so the sheet cannot
  build a room.
- Evidence: decoding `sewers.png` per tile shows content in columns 1–3, 5–7,
  9–11, and 13–15 of rows 1–3 only; every other tile is empty.
- Workaround: Floodworks takes its structure from the three `bg-sewers-*`
  layers and its water from the water sprites, and the tiles supply only the
  surface the player stands on. The room needs one tile layer, not the three
  the city block uses. `validateWalkwayBlocks` checks that every tile belongs
  to a whole 3×3 unit, which is the check a fixed-assembly sheet needs and a
  freeform kit does not.
- Status: worked around, and it settles how much of an area's identity can
  come from its background: nearly all of it.

## 2026-08-04 — a walkway unit is drawn in perspective, and the catalog cannot say so

- Category: asset gap
- Trying to do: decide where a player's feet go on a `sewers` walkway unit.
- What happened: the unit's art fills the bottom 37px of its 48px block, so
  the top 11px of its first tile row is transparent. Read as "the art is
  inset", that puts the walking surface 11px off the tile grid and makes a
  draw offset to correct it. Read correctly, the unit is drawn in perspective
  and belongs behind the player: the 5px of art above the second row's top
  edge is the walkway's far lip, the player walks the line where that row
  begins, and the two rows below it are the solid walkway. The second reading
  needs no offset at all — the walking line is already a tile boundary, and
  collision on those bottom two rows matches the art on both edges.
- Evidence: measured against `sewers.png`, the art runs from 11 to 47 within
  the block. The second row's top edge is at 16, leaving 5px above it and
  exactly 32px — two whole tile rows — below.
- Workaround: none. Placement follows the art. `FLOODWORKS_CAP_IDS` is the
  unit's second row rather than its first, and the tile layer draws behind the
  player so the far lip passes behind the body.
- Status: no engine or asset change needed. What the catalog cannot express is
  the part that matters: which row of a multi-tile unit is the walking line,
  and that the unit expects to be drawn behind the player. Both had to be
  measured off the pixels and reasoned about from the perspective the art is
  drawn in.

## 2026-08-04 — a tileset's composition is only legible from the pack's own example map

- Category: asset gap
- Trying to do: build a Broadcast Spire room from `warped-caves`, the third
  terrain set in as many areas.
- What happened: measuring the sheet per tile identified the pieces but not
  what they are for, and the first composition used them wrongly at every
  level. The set draws a cave — floors with lit stone tops, roofs of teeth
  hanging over the passage, vines that hang from a roof, water, and a plain
  fill sheet for the rock behind. It was read instead as free-standing
  platforms, and the room came out as a flat shelf hanging under an open sky,
  with the roof pieces used as the shelf's underside and the hanging vines
  planted on the ground.
- Evidence: the pack ships `Assets/Phaser Demo/assets/maps/map.json`, a 66×23
  Tiled map by the sheet's own author. Rendering it settles every question the
  pixels left open in one pass: a floor is an edge tile over the tile one sheet
  row below it, a roof is the same pair upside down, both tilesets are used
  together with the wall sheet behind, and the passages are left empty so the
  parallax cavern shows through them.
- Workaround: `src/features/world/spireAssets.ts` records the vocabulary the
  example map demonstrates, and `validateRockSurfaces` holds every edge tile
  to carrying its own body — the one rule the sheet imposes, and the one whose
  failure is visible as a lit stone line hanging in mid-air.
- Status: worked around. The lesson is about the library rather than this
  sheet: three areas have now each needed a different composition method, none
  of which the catalog describes, and the fastest route to the right one was a
  file the library does not carry. Candidate fix: keep the source pack's own
  example map beside a tileset, or record the composition rules the example
  demonstrates, so an agent does not have to infer them from pixels.

## 2026-08-04 — `Save.saveSlot` does not order two writes to one slot

- Category: missing primitive
- Trying to do: write two rapid autosaves to the `auto` slot and keep the last
  state, which is what an autosave after a checkpoint and an autosave after the
  progression transaction that follows it do.
- What happened: the two writes race, and on a store where the first is the
  slower one the earlier state survives in both the slot and its manifest
  entry. `Save.saveSlot` writes the slot data and only then enters the
  per-store manifest queue, so the queue serializes the manifest
  read-modify-write and leaves the slot data unprotected. The manifest does not
  correct it either: each call's manifest update is enqueued when its own slot
  write resolves, so the manifest agrees with whichever slot write landed last.
- Evidence: `saveSlot` in `node_modules/@yagejs/save/dist/index.js` awaits
  `adapter.write(slotKey(...))` before `updateManifest`, and the comment on
  `manifestQueues` says slot data writes "target distinct keys" and are left
  unaffected — true across slots, false for two writes to one slot.
  `scenarios/persistence/saveQueue.scenario.ts` writes the pair over an adapter
  that holds each write open for a set time and asserts the result.
  `Save.autoPersist` has the rule this needs and does not expose it: an
  in-flight flag plus a dirty flag, re-reading `serialize()` at flush time.
- Workaround: `SaveQueueStandIn` chains writes per slot, so a write waits for
  the previous write to the same slot. `SaveCoordinator` owns that rule when it
  is built; nothing in the engine provides it.
- Status: worked around. Candidate engine fix: run slot data through the same
  per-store queue as the manifest, or expose `autoPersist`'s coalescing rule
  for slots.

## 2026-08-04 — `@yagejs/ui` and `@yagejs/ui-react` have no focus system

- Category: missing primitive
- Trying to do: navigate one pause-menu screen with a keyboard, a D-pad, and
  the left stick, which the game needs on every screen because it must be
  completable without a pointer.
- What happened: neither package offers anything to build on. Searching both
  `.d.ts` files for `focusable`, `setFocus`, `navigateFocus`, and a focus
  manager returns no hits: `@yagejs/ui` exports widgets, layout, and a floating
  overlay, and `@yagejs/ui-react` exports components and hooks. The two
  mentions of focus in `@yagejs/ui` are prose about what can trigger a widget
  state, not an API. Every part of menu navigation is game code — the focus id,
  the directional neighbours, per-tab memory, confirm, cancel, and the busy
  state that refuses input while a command runs.
- Evidence: `node_modules/@yagejs/ui/dist/index.d.ts` and
  `node_modules/@yagejs/ui-react/dist/index.d.ts`.
  `scenarios/menu/MenuFocusStandIn.ts` is the whole model the screen needed.
- Workaround: own the focus model in game code, which every game shipping a
  gamepad-completable menu has to repeat.
- Status: worked around. Candidate engine fix: a focus manager in `@yagejs/ui`
  with authored neighbours, since geometry-derived focus order is wrong as soon
  as a screen is not a grid.

## 2026-08-04 — an analog stick has no edge or repeat conversion

- Category: missing primitive
- Trying to do: move menu focus one item per push of the left stick, and repeat
  while it is held, the way every pad-driven menu behaves.
- What happened: `InputManager.getStick(side)` reports a level — a deadzoned,
  magnitude-clamped vector — and nothing converts it to the edges a menu reads.
  Buttons get that conversion from the manager through `isJustPressed`; sticks
  get no equivalent. The game has to pick a push threshold, hold the direction
  it last acted on, re-arm it only once the stick falls back towards centre,
  and run its own initial delay and repeat interval.
- Evidence: `getStick` and `fireGamepadAxis` in
  `node_modules/@yagejs/input/dist/api-B6e5SEtY.d.ts`; the manager exposes
  `setDeadzones` and nothing else about axis edges.
  `MenuDirectionalInputStandIn` in `scenarios/menu/MenuFocusStandIn.ts` is the
  conversion, at about thirty lines.
- Workaround: convert in game code. A mutation that drops the re-armed
  direction makes one push walk the whole screen, so the rule is load-bearing
  rather than decoration.
- Status: worked around. Candidate engine fix: a stick-direction edge query on
  `InputManager`, with the repeat timing as options, so every game does not
  re-derive it.

## 2026-08-04 — destroying several physics entities in one flush no longer throws

- Category: infra/test gap
- Trying to do: confirm the constraint the staged section drain is built
  around, which is that retiring several Rapier-backed entities inside one
  end-of-frame flush can throw wasm-bindgen's aliasing error from
  `PhysicsWorld.removeBody`.
- What happened: it did not reproduce on `@yagejs/physics` 0.10.1. A boss tree
  of ten physics entities retired in a single frame completes, and so does
  destroying only the roots and letting the child cascade take the tree — the
  harsher of the two, and the shape a scene replacement produces.
- Evidence: `scenarios/world/teardown.scenario.ts` runs all three drains
  against the game's own plugin composition. `atOnce` and `cascade` pass with
  no engine callback error recorded, and the physics world is back to the
  floor's single body two frames later.
- Workaround: none needed for the crash. The staged drain stays, because the
  ordering rule it enforces is independent of it: a parent's `destroy()`
  cascades to its children, so a root retired before its descendants takes them
  with it, and post-order plus one physics entity per frame is what keeps a
  retirement observable leaf by leaf.
- Status: recorded. The guidance to retire at most one physics entity per frame
  is written against an older version and is worth re-checking before it is
  repeated as a general rule.

## 2026-08-04 — the first lab run after a bulk rewrite reports a phantom failure

- Category: infra/test gap
- Trying to do: verify a refactor that rewrote every file under `scenarios/` in
  one pass, by running `npm run lab:test` against the result.
- What happened: `combat/catchBody/composition` failed with a receipt count of
  3 where the drive asserts 1 — the signature of a hit ledger admitting the
  same delivery several times. The scenario has passed every run since, nine in
  a row across single-file, full-suite, and screenshot configurations, with the
  counters measured correct at each stage of the drive. The two runs that
  followed the failure each edited the scenario file, which is what would clear
  a stale module.
- Evidence: `scenarios/combat/CatchTargetStandIn.ts` and the drive's assertions
  are byte-identical to their pre-refactor versions, so nothing in the ledger
  path changed. The shared skill's gotcha list already records the mechanism
  for the panel: hot-swapped modules duplicate classes and module-level state,
  which fabricates impossible bugs. The lab extends the project's own Vite
  config, so a headless run started while the module graph is still settling
  can hit the same thing.
- Workaround: after a bulk rewrite, discard the first lab result and run again
  before diagnosing anything. A failure that does not reproduce on a second run
  is the module graph, not the game.
- Status: worked around. The cost is real — a phantom duplicate-delivery
  failure reads exactly like a broken ledger, and it took nine runs and two
  instrumented probes to establish that nothing was wrong. Candidate fix: have
  `yage-lab test` start from a cold module graph, or report when it served a
  scenario from a cache written before the file's current mtime.

## 2026-08-05 — the buffered-press query counts on a clock gameplay cannot use

- Category: API friction
- Trying to do: hold a jump press for a short window so it can be spent by a
  landing that has not happened yet, which is the jump buffer `TDD.md`
  specifies.
- What happened: `InputManager.consumeBufferedPress(action, windowSeconds)` is
  exactly that query, and it is the only one in the engine. Its window is
  measured on the input clock, which `getClockTime` documents as advancing from
  unscaled frame time and as unaffected by scene pause or time scale. Every
  movement rule in this game is required to run on simulation time, so a press
  buffered through it would keep counting down through a pause and would drift
  from the fixed step under any time scale.
- Evidence: `@yagejs/input`'s `InputManager` declares `getClockTime()` as "the
  raw input clock … Scene pause and time scaling do not affect it", and
  `consumeBufferedPress` reads that clock.
- Workaround: `PlayerMotor` owns the buffer itself, counting it down in the
  fixed step alongside coyote time and the other movement resources. The press
  edge still comes from the input manager; only the window is game-owned.
- Status: closed by yage PR #256, in `@yagejs/input` 0.10.2:
  `consumeBufferedPress(action, seconds, { clock })` takes a clock, and
  `SceneTime` satisfies the shape. `PlayerMotor` keeps its own countdown,
  because that countdown is coupled to the fixed step and works; the option is
  the one to reach for when the buffer is next touched.

## 2026-08-05 — an animated sprite cannot hold a chosen frame

- Category: API friction
- Trying to do: show one held pose for a state the installed art has no
  animation for — a body falling, gripping a wall, or crouching while it walks.
- What happened: `AnimatedSpriteComponent` exposes `play`, `stop`, and
  `isPlaying`, and nothing that selects a frame. `stop()` leaves whatever frame
  playback happened to reach, which is not a pose. `play({ loop: false })`
  reliably holds the last frame, so an end-of-cycle pose is available and any
  other one is not.
- Evidence: the component's shipped types carry no frame accessor; the frame
  API is on the Pixi `AnimatedSprite` the component wraps and exposes as
  `animatedSprite`.
- Workaround: `scenarios/shared/playerVisuals.ts` reaches through to
  `animatedSprite.gotoAndStop(frame)` for the held poses and uses
  `play({ loop: false })` where the pose is the last frame. Reaching into the
  display object for anything else is what `GAME.md` and the engine's own
  guidance warn against, so this stays confined to that one function.
- Status: closed by yage PR #253, in `@yagejs/renderer` 0.10.2:
  `AnimatedSpriteComponent.gotoFrame(index)` stops playback and holds the
  frame, throwing on an index outside the source, and a `frame` getter reads
  the current index. `scenarios/shared/playerVisuals.ts`,
  `scenarios/player/art.scenario.ts`, and
  `scenarios/player/sheetLibrary.scenario.ts` call it, and none of them reaches
  into the Pixi display object for a frame any more.

## 2026-08-05 — a one-shot animation replays as a single frozen frame

- Category: API friction
- Trying to do: play the jump sheet once per jump — the whole cycle on the
  rise, its last frame held for the fall.
- What happened: `AnimatedSpriteComponent.play()` resumes the wrapped Pixi
  `AnimatedSprite` from whatever frame it is on, and nothing rewinds it. A
  non-looping pass ends parked on its last frame, so playing the same sheet
  again completes within a frame and shows only that frame. Every jump after
  the first showed the descent pose for the whole arc.
- Evidence: Pixi documents `play()` as a resume — "it will continue from where
  it left off" — and the component forwards to it without a frame reset. With
  the rewind in place, a frame trace of two consecutive jumps in
  `gym/movementLane` shows both passes advancing through frames 1→4; the freeze
  itself was observed in the panel, not traced.
- Workaround: `scenarios/shared/playerVisuals.ts` calls
  `animatedSprite.gotoAndStop(0)` before `play()` when a non-looping mode
  starts on a sheet that is not already showing.
- Status: closed by yage PR #253, in `@yagejs/renderer` 0.10.2:
  `play({ fromStart: true })` starts a pass at frame 0 and a bare `play()`
  still resumes. `scenarios/shared/playerVisuals.ts` passes the option instead
  of rewinding the display object first.

## 2026-08-05 — one combat scenario fails intermittently under load

- Category: infra/test gap
- Trying to do: run the whole scenario suite as a gate.
- What happened: `combat/catchBody/composition` fails in roughly one run in
  three, reporting three receipts where the drive asserts one. It passes on the
  next run with no change. The other 48 scenarios are stable.
- Evidence: observed in 3 of 8 consecutive `npm run lab:test` runs on
  2026-08-05, and in none of the runs of that scenario on its own. It appeared
  during a session that was running suites back to back, so machine load is the
  suspected trigger. Nothing in the movement work touches that scenario: its
  only shared import is `scenarios/shared/ScenarioReadout.ts`, whose change was
  text wrapping.
- Workaround: none. The scenario is not part of the movement work, so it was
  left as it stands rather than adjusted to hide the failure.
- Status: not reproduced since the combat step rebuilt the scenario. The
  suspected culprit — a stand-in attack window opened by a frame counter —
  was replaced by a real `Abilities` hitbox window, and the drive now expects
  one receipt per hurtbox rather than one per combatant. The scenario passed
  every one of the roughly ten full-suite and single-file runs of the combat
  step, several of them back to back under the same kind of load.

## 2026-08-05 — a body held into an overhang rests about two pixels inside it

- Category: API friction
- Trying to do: keep the standing body out of geometry it does not fit in,
  which is what the stance's clearance test exists for.
- What happened: the clearance test decides whether the taller shape fits
  before it is adopted, and nothing re-checks it afterwards. A body standing
  against the vertical face of a low overhang, with the direction held into it,
  rests up to 1.97px inside that overhang — measured on the gym's 2-tile
  passage at a run speed of 170px/s, which is 2.8px of travel per fixed step.
  The depth tracks the step's travel, so it is the commanded velocity being
  re-applied every step against a solver that pushes back out over several.
- Evidence: a driven run that walks into the passage edge standing and presses
  jump each step reports 35 steps where the standing box overlaps the ceiling,
  worst 1.97px. Zeroing the commanded speed when a wall is reported on the side
  of travel changed the worst case to 1.92px, so the contact is not being
  classified as a wall at that depth — a shallow horizontal overlap under an
  overhang does not report the normal the wall test looks for.
- Workaround: none applied. The clearance test is swept by the step's travel,
  which removes the case where a body stands up clear and moves into geometry
  in the same step; what remains is this resting depth.
- Status: open. `PhysicsConfig` exposes only gravity and `pixelsPerMeter`, so
  the solver's push-out rate is not tunable from the game. The three options
  are a collide-and-slide sweep before committing horizontal speed, a skin
  around the body collider, and an engine-side penetration setting. The first
  is the real fix and is character-controller work rather than a tuning change.

## 2026-08-05 — an entity's own update hooks are silently never called

- Category: API friction
- Trying to do: give the game-owned projectile entity its per-step flight —
  move, expire, deliver — as a `fixedUpdate` method on the `Entity` subclass.
- What happened: the method never ran. `ComponentUpdateSystem` iterates
  entities and calls hooks on their *components* only; `Entity` has no tick
  hooks, and a method named `update` or `fixedUpdate` on one is dead code with
  no warning. The shot sat at its spawn point for its whole lifetime, which
  read as a physics or spawning fault, not as a hook that does not exist.
- Evidence: `src/features/combat/ProjectileEntity.ts` — the flight ran only
  after moving into a one-line component (`ProjectileFlight`) that delegates
  to the entity.
- Workaround: a private component per entity class that needs a tick,
  delegating to the entity.
- Status: worked around. Candidate fix: either call entity-level hooks, or
  warn in debug mode when an entity declares `update`/`fixedUpdate` — the
  silent version costs a debugging session.

## 2026-08-05 — delivery-step offsets rotate with the aim instead of mirroring

- Category: API friction
- Trying to do: place a melee hitbox at torso height in front of the caster —
  an `offset` of `{ x: reach, y: -22 }` from a foot-anchored transform.
- What happened: `hitbox` and `spawn` resolve `offset` in a facing-local
  frame rotated by the aim angle. An aim of `(-1, 0)` is a rotation by π, so
  the y-offset flips sign too: the left-facing swing lands *below* the feet,
  underground. A side-view game wants offsets mirrored across x, never
  rotated; full rotation is right only for free-aim games.
- Evidence: `src/features/combat/abilities/playerAbilities.ts` — every strike
  keeps `offset.y` at 0 and carries the height in the shape (a 64px-tall box
  from the feet line), and the projectile muzzle uses an absolute `position`
  resolver instead of an offset.
- Workaround: as above — offsets on the aim axis only, height in the shape or
  in a `position` resolver.
- Status: worked around. Candidate fix: a `mirror` mode for delivery-step
  offsets, or a documented convention in the addon's reference — nothing in
  it says a y-offset changes sides with the aim.

## 2026-08-05 — a projectile's delivery direction degenerates at contact

- Category: API friction
- Trying to do: let the player's guard cone judge an incoming shot by where
  it came from.
- What happened: `HitDelivery.deliver(target, from)` computes the payload
  direction from `from` to the target's position. A projectile delivering
  from its own overlap position stands nearly on top of its victim, so the
  direction is a near-vertical sliver of the contact — measured `x` of +0.19
  for a shot that flew in horizontally — and the frontal-cone policy read it
  as an attack from nowhere, letting the shot through the guard.
- Evidence: `src/features/combat/ProjectileEntity.ts` delivers from the
  launch origin instead, and `mechanical-tests/gym/combat`'s guard situation
  fails on the shot's own position — that mutation was run and caught.
- Workaround: deliver from the launch origin, so the direction approximates
  the flight.
- Status: worked around. Candidate fix: let a delivery carry an explicit
  direction, the way the addon's own `Projectile` could pass its velocity —
  its deliveries have the same degeneracy.

## 2026-08-05 — a guard's punish payload cannot carry per-hit identity

- Category: API friction
- Trying to do: key the parry's punish delivery the way every other delivery
  is keyed, with a fresh attack instance per parry.
- What happened: `GuardParams.punish` is static data — the addon documents
  that no `StepContext` exists at engage time — so the payload cannot mint an
  identity per parry. Under a ledger keyed by attack instance, a second parry
  against the same combatant would be refused as a repeat of the first.
- Evidence: `src/features/combat/CombatantRoot.ts` — the ledger stage admits
  the one shared `PARRY_PUNISH_INSTANCE` without recording it.
- Workaround: a reserved instance the ledger passes through.
- Status: worked around. Candidate fix: accept a fire-time builder for
  `punish`, mirroring `HitSpec`.

## 2026-08-05 — the lab runner truncates a failing assertion to about forty characters

- Category: infra/test gap
- Trying to do: read a failing scenario's assertion message to diagnose it,
  after a headless `yage-lab test` run.
- What happened: the report shows one line per failure and cuts the
  expected/actual text at roughly forty characters, so any assertion built to
  carry diagnostic context — a joined list, a serialized object — arrives as
  `expected '{"p1":null,"p2":"riot-walker","hits":…' to be …`. Diagnosing one
  selection failure took four edit-and-rerun cycles, each asserting one more
  fragment of the same value, because no run could show the whole message.
- Evidence: the `gym/manipulation` selection diagnosis on this date; each
  intermediate run's report line ends in an ellipsis at the same width.
- Workaround: assert on one compact fact per run, and re-run with
  `--scenarios <file>` to keep the cycle short.
- Status: closed by yage PR #254, in `@yagejs-tools/lab` 0.1.1, which prints
  the compared values in full. The same release adds a per-call `dtMs` to
  `step` and `until`, a real-time playback checkbox beside Run in the panel,
  and `--screenshot-view camera`.

## 2026-08-06 — a collision reports no impulse or relative speed

- Category: missing primitive
- Trying to do: judge how hard two bodies met, so a wall slam and an
  enemy-into-enemy impact can be scored from what the collision actually was.
- What happened: `CollisionEvent` carries `other`, `otherCollider`,
  `started`, `contactNormal`, and `contactPoint`, and nothing about the
  collision's magnitude. Rapier computes a contact impulse while solving, and
  the wrapper does not surface it. There is no relative velocity at the
  contact either, so the only magnitude available to a game is one it
  recorded itself.
- Evidence: `node_modules/@yagejs/physics/dist/api-ksqGw1ij.d.ts`, the
  `CollisionEvent` interface. `src/features/combat/PressureState.ts` carries
  the substitute: the velocity committed for the step is stored before the
  physics step, and the impact speed is that sample projected onto the
  engine's own `contactNormal`. The body read inside the callback is already
  stopped by the solver, which is what makes the stored sample necessary —
  `mechanical-tests/combat/impactSpeed` fails when it reads the body instead.
- Workaround: the pre-physics velocity sample, one per pressure state.
- Status: closed by yage issue 248, in `@yagejs/physics` 0.10.2.
  `CollisionEvent` now carries `contactImpulse` — the magnitude of the total
  impulse the solver applied, friction excluded — and `contactImpulseVector`,
  oriented from this entity toward the other with the same length. Dividing by
  a dynamic body's `getMass()` gives the speed change it took.

  What the installed API can and cannot do, checked against the shipped types
  on 2026-08-08:

  - Both fields are present **only** on started, non-sensor collisions with a
    contact manifold, and may be 0 for a grazing contact. A caught enemy is a
    sensor while it is held, so nothing that happens during a catch can be
    scored from an impulse — only a slam or a throw, which fly the enemy
    solid.
  - The raw magnitude scales with mass, and mass is density × area in metres,
    so a threshold on `contactImpulse` itself would need one number per body
    size — the manipulation lane's four dummies are four different sizes.
    Dividing by `getMass()` removes that: the speed change is in the same
    units for every body, so one threshold serves all four.
  - It measures the solver's response, not the approach. `TDD.md` states the
    scoring rule in approach speed, which is what the pre-physics sample
    holds; an impulse-based rule would restate it in a second quantity.

  The pre-physics sample therefore stays as the qualifying test: it is built,
  it covers the sensor case an impulse cannot, and it is the quantity the rule
  is written in. Scoring from `contactImpulse / getMass()` is a viable
  alternative for the solid-body impacts, on one threshold, if the panel says
  a slam does not read.

## 2026-08-06 — no joint or constraint primitive

- Category: missing primitive
- Trying to do: hold two bodies together through an elastic tether, and swing
  a body around an anchor at a fixed radius.
- What happened: the physics package exposes rigid bodies, colliders,
  raycasts, shape casts, and overlap queries. It defines no joint, spring,
  rope, or distance constraint, so bodies can only be related through
  velocities a game writes each step. Rapier supports joints underneath.
- Evidence: `node_modules/@yagejs/physics/dist/index.d.ts` and its API chunk —
  the exported surface holds no joint type, and `applyForce`/`applyImpulse`
  are the only force entry points.
- Workaround: none built. A radius constraint can be integrated game-side by
  clamping distance and keeping tangential velocity, which then owes the
  one-command-per-body rule its own motion owner.
- Status: closed by yage issue 249, in `@yagejs/physics` 0.10.2.
  `PhysicsWorld.addJoint(bodyA, bodyB, config)` takes two
  `RigidBodyComponent`s and either a `SpringJointConfig` (`restLength`,
  `stiffness`, `damping`, optional per-body anchors) or a `RopeJointConfig`
  (`length`, optional anchors), and returns a `JointHandle`.

  What the installed API can and cannot do, checked against the shipped types
  and implementation on 2026-08-08:

  - `JointHandle` exposes `attached` and `remove()` and nothing else, so a
    joint's length cannot change after it is created. A reel that shortens its
    rope has to remove and re-add a joint every fixed step.
  - Spring and rope are the whole set: there is no fixed, revolute, prismatic,
    or motorised joint.
  - Both sides must be live bodies in the same world — `_requireLiveBody`
    throws otherwise. Terrain qualifies in this game, because `RoomSolid` and
    `GymSolid` each carry a static `RigidBodyComponent`.
  - A joint on this player holds a distance but carries no momentum.
    `PhysicsSystem.stepScene` solves the joint inside `world.step` and its
    `postStep` writes the resulting pose back, so the constraint is already in
    the body's position by the time the next fixed step runs.
    `MotionCommitter` writes velocity and gravity scale and never position, so
    it cannot undo that. What it does replace is the velocity the joint gave
    the body, so the tangential speed a pendulum arc accumulates is gone on
    the next step: a rope on the player would leash it without ever swinging
    it.

  The grapple stays authored rather than simulated: the design session's
  decision 3 declined a simulated tether, and these limits are why the
  decision does not need revisiting. The elastic anchor and the swing that
  decision 3 deferred are what the momentum limit would bite; a plain leash is
  the one shape a rope joint could carry today.

## 2026-08-06 — a closed Tiled polygon's base vertices catch the walking body

- Category: API friction
- Trying to do: walk a box-collider body across a Tiled polygon ramp resting
  on a rectangle floor, in both directions.
- What happened: `extractCollisionShapes` closes a Tiled polygon, so the
  outline's bottom edge lies flush along the floor rectangle's top face and
  its base vertices sit exactly on the walking line. Entering the ramp from
  one side, the body's foot corner catches the base vertex each step: the
  commanded 152 px/s crossing crawls at 51 px/s and never completes. The
  other direction crosses cleanly, so the catch depends on approach
  direction. This is the polyline internal-edge artifact reaching through
  the wrapper.
- Evidence: `scenarios/world/spireRoom.scenario.ts` backward mound crossing
  against `public/assets/spire-room.json` object 11 — 240-step cap reached at
  x 565.9 with vx -51. The same drive against a copy whose base vertices are
  extended one tile below the floor top completes in 37 steps at full speed.
- Workaround: none known. Burying the base vertices one tile below the floor
  top was tried and disproven: the six-point chain stops colliding with the
  body entirely — the crossing completes at flat speed with zero sloped
  ground normals, which the traversal scenario's velocity band catches. The
  first read of that experiment mistook pass-through for a fix; the
  completion time alone cannot tell them apart.
- Status: closed by yage PR #255, in `@yagejs/physics` 0.10.2. The backward
  mound crossing that used to hit the 240-step cap at vx -51 completes in 37
  steps at the full -180. The authored map is unchanged: the base vertices sit
  on the walking line exactly as before.

## 2026-08-08 — a collision event reported an arbitrary manifold, not the deepest

- Category: API friction
- Trying to do: classify the surface under the player from the collision
  events its body collider reports, on terrain authored as Tiled polygons.
- What happened: a collider pair produces one contact manifold per polyline
  segment, and the event carried whichever Rapier returned first. A body
  arriving at the Spire mound from one side received the normal of the chain's
  closing edge, which is coplanar with the floor and describes the ramp as
  level ground.
- Evidence: the defect was found while building the mound crossing in
  `scenarios/world/spireRoom.scenario.ts` and never written down here; yage
  PR #252 closed it before this entry was opened.
- Workaround: `PlayerContacts.record` kept one record per contacting entity
  and replaced it only when a later event reported a greater
  `penetrationDepth`, so the deepest normal seen so far won.
- Status: closed by yage PR #252, in `@yagejs/physics` 0.10.2:
  `contactNormal`, `contactPoint` and `penetrationDepth` come from the solver
  contact with the greatest overlap across every manifold. Adopting it moved
  nothing measurable — all 78 situations that existed before this step
  reported the same result on 0.10.1 and 0.10.2, with identical values in the
  six that fail. The game-side depth ranking is therefore redundant, and it is
  still in place: retiring it belongs with the entry below, which is the rule
  that has to change first.

## 2026-08-08 — a contact normal is reported only when the contact starts

- Category: missing primitive
- Trying to do: read the angle of the surface under the player each fixed
  step, on a room whose terrain is authored as Tiled polygons.
- What happened: collision events fire on the started and ended edges of a
  collider pair and nowhere else, and only the started edge carries a
  `contactNormal`. `extractCollisionShapes` turns a whole Tiled polygon into
  one polyline collider, so a mound's two ramps and its flat cap are one
  collider: a body that walks from the cap onto a ramp produces no event, and
  whatever normal it recorded on first touch is the only one it has. There is
  no query for the live contact of a pair — `ColliderComponent.getOverlapping`
  returns entities, and `raycast`, `castShape` and `queryShape` measure
  geometry rather than the contact the solver actually solved.
- Evidence: `world/spireRoom` fails at the idle-on-slope assertion with a
  ground normal of 1 where the surface is 0.894. A frame trace of the body
  crossing the mound leftward and returning shows six events for the whole
  passage: the mound contact starts on the right ramp with `ny=0.894`, ends
  on the cap, restarts on the flat cap with `ny=1.000`, and then reports
  nothing at all while the body walks 45px down the left ramp and back up it.
  Crossing rightward passes only because the body leaves the surface at the
  ramp-to-cap junction, which ends the contact and starts a fresh one.
- Workaround: `PlayerContacts` measures the surface with its own shape casts
  each fixed step and takes both the ground normal and the ground/wall/ceiling
  classification from them, keeping the recorded contact only as the set of
  entities the body touches and as the fallback for a surface no cast reports.
  That is four extra casts per body per step, and it is a policy the engine
  could answer directly.
- Status: open. Candidate engine fix: a query for a pair's current contact, or
  a per-step contact event while two colliders touch. Failing that, splitting
  a Tiled polygon into one collider per segment would at least make each
  surface change an event.

## 2026-08-08 — no effect for contrast or saturation as tuned numbers

- Category: missing primitive
- Trying to do: give the Broadcast Spire its treatment, which raises contrast
  and saturation rather than washing the room with a colour.
- What happened: `@yagejs/effects` has eighteen presets and none of them takes
  a contrast or a saturation amount. `colorGrade` is the nearest: it takes one
  of seven named presets — neutral, sepia, grayscale, negative, night, warm,
  cool — plus a blend amount, so it cannot express two independently tuned
  numbers. `colorize` and the new `TilemapComponent.tint` are both channel
  multiplies, which is the wash this room is deliberately not using.
- Evidence: `node_modules/@yagejs/effects/dist/index.d.ts` — `ColorGradePreset`
  is a union of the seven names, and `ColorGradeOptions` carries `preset` and
  `amount` only.
- Workaround: `scenarios/world/spireRoom.scenario.ts` keeps a raw Pixi
  `ColorMatrixFilter` on the tilemap containers and calls `contrast` and
  `saturate` on it. The other two rooms moved to `TilemapComponent.tint`, so
  the Spire is the only room still reaching through a tilemap's container to a
  Pixi filter.
- Status: open. Candidate engine fix: a `colorAdjust` effect taking contrast,
  saturation, brightness and hue as numbers. `ColorMatrixFilter` already
  implements all four, so the preset list is the only thing in the way.

## 2026-08-08 — a shape query cannot see the contact filters the solver uses

- Category: missing primitive
- Trying to do: keep the player's downward ground snap and its pose probes
  honest about one-way platforms, which the game will build once rooms carry
  drop-through floors.
- What happened: `PhysicsWorld.raycast`, `castShape`, `queryShape` and
  `queryRadius` take `filterGroups` and `excludeEntity` and nothing else. A
  collider's contact filter — the per-pair veto `ColliderComponent.oneWay`
  installs and `setContactFilter` replaces — runs inside the physics step, and
  no query consults it. Neither does a body's `dropThrough` window. So a query
  reports a one-way platform as solid from underneath, and reports it as solid
  for a body that is deliberately dropping through it.
- Evidence: `node_modules/@yagejs/physics/dist/api-ChwGtYtV.d.ts` — the four
  query signatures carry `filterGroups`, `rotation` and `excludeEntity`;
  `_evaluateContactFilter` is marked `@internal` and takes a
  `ContactCandidate` the caller has no way to build.
- Workaround: none needed yet, and none in place. No one-way platform is
  constructed in this game, so nothing queries one. When they arrive, the
  ground snap in `PlayerContacts.takeGroundSnap` and the four probes in
  `PlayerContacts.probeSurfaces` would each have to re-implement the one-way
  policy against the hit entity, which duplicates the engine's own rule and
  can only cover filters the game itself wrote.
- Status: open, dormant. Candidate engine fix: an opt-in on the query options
  that runs each candidate through the same contact filter the step would, or
  a `solidFor(entity)` predicate on `ColliderComponent` the game can call from
  its own query loop.

## 2026-08-08 — one lab frame can run two fixed steps under load

- Category: infra/test gap
- Trying to do: run the whole scenario suite as a gate, several times in a row.
- What happened: `world/scheduler/order` failed once in eight consecutive
  `npm run lab:test` runs, recording the six stage names twice for a drive that
  takes one `step(1)`. `gym/movement/contactsFollowPose` failed once over the
  same eight runs, on a position read taken four steps after an `until` that
  keys off the drawn pose. Both pass on their own and on the next full run.
  Two fixed steps inside one frame is what the scheduler result shows directly,
  and it also explains the position read: the drawn pose blends the last two
  fixed steps by the frame's leftover time, so a threshold on it trips a frame
  either side of where a quiet run puts it.
- Evidence: the scheduler failure prints
  `['classify','support','motion','grapple','combat','enemy']` twice against a
  drive that steps once. Eight full-suite runs on 2026-08-08 produced those two
  failures and no others; `npx yage-lab test --scenarios
  scenarios/world/scheduler.scenario.ts` passes every time.
- Workaround: a drive settles the body before it measures a position — an
  `until` on the state it is waiting for, then a fixed number of steps. That
  covers the second failure and not the first, which is about the frame the
  harness gives the scenario rather than about what the drive reads.
- Status: open. Candidate lab fix: drive a scenario's frames from the fixed
  clock rather than from a rendered frame, so `step(n)` is exactly n fixed
  steps whatever the machine is doing.

## 2026-08-08 — `Component.sibling` returns a new proxy on every call

- Category: API friction
- Trying to do: hold "one motion command per body per fixed step" by keying a
  `Map` on the `RigidBodyComponent` each command names, so that a second writer
  for one body is counted and a scenario can assert the count is zero.
- What happened: `sibling(cls)` returns `new Proxy({}, { get, set })` and builds
  a new one on every call. Two components on one entity that each hold
  `this.sibling(RigidBodyComponent)` end up with two objects forwarding to the
  same body, so the map holds two keys. `MotionCommitter.pending` stored both
  commands, `commit` applied both in insertion order with the last write winning
  per field, and the refusal counter never moved. The proxy carries only `get`
  and `set` traps, so `instanceof`, `in` and `Object.keys` fail on it as well,
  and the get trap binds every function-valued property, so `proxy.update !==
  proxy.update`. Nothing warns about any of it:
  `../yage/docs/src/content/docs/concepts/entities-and-components.mdx:313` says
  the lookup is "deferred until first access and then cached", which reads as
  one shared reference.
- Evidence: `../yage/packages/core/src/Component.ts:166`. In this game
  `CaughtController` (`src/features/grapple/CaughtController.ts:90`) and
  `PressureState` (`src/features/combat/PressureState.ts:119`) hold separate
  proxies for one enemy body, and `ManipulationDummy` submits with the real
  component (`scenarios/gym/entities/ManipulationEntities.ts:88`) — three keys
  for one body. Keying the map on `command.body.entity` and throwing on a second
  command turns up one real pair across 115 scenarios,
  `gym/manipulation/instantHook`, where a queued catch release and a pressure
  launch both write the light scout in one step. Read off that body one step
  later, `velocityY` is -30 with both commands applied and 0 with the second
  refused, and no assertion tells the two apart. Three assertions therefore
  prove nothing: `scenarios/gym/manipulation.scenario.ts:685` and `:1638`, and
  `scenarios/combat/catchBody.scenario.ts:213`.
  `scenarios/world/scheduler.scenario.ts:201` still holds, because it hands both
  writers the same real component — the one shape production code never builds.
- Workaround: key `MotionCommitter.pending` on `command.body.entity` rather than
  on the component. `Component.entity` is a public field, so the get trap
  forwards it and returns the real `Entity`, and one entity carries one
  `RigidBodyComponent`. That is correct whether the caller passed a proxy or the
  component itself, which matters here because this game has both. Resolving the
  body once with `entity.get(cls)` in each writer also works and is worth doing
  as well, but it is silent: the next component written with `sibling` reopens
  the hole.
- Status: open, and not yet applied. First-come refusal on a corrected key drops
  the launch in `instantHook`, so the arbitration between a catch release and a
  queued launch has to be settled before the guard can be made loud. Two addon
  APIs carry the same exposure with no defence: `Interactor.interact` compares
  by reference against a `Set` and returns silently when handed a proxy
  (`../yage/packages/addons/interaction/src/Interactor.ts:159`), and
  `setStaggerWindowEnabled` throws
  (`../yage/packages/addons/abilities/src/components/Stagger.ts:111`). Candidate
  engine fix: state in both `docs/llms/` and the concept docs that a sibling
  reference is a view rather than an identity — never a map key, never an
  `instanceof` subject — and name `entity.get(cls)` as the form to use when
  identity matters. Caching the proxy on the entity would make
  `a.sibling(X) === b.sibling(X)` hold, but a proxy still never equals
  `entity.get(X)`, and a codebase that mixes both forms gains nothing.