← 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.