← all games

arena-shooter-fable — FRICTION.md

# Friction log — arena-shooter-fable

Build: top-down arena shooter, YAGE 0.9.0, scaffolded with `create-yage --template minimal`.
Categories: API friction / missing primitive / recipe gap / docs ambiguity / asset gap / test-infra gap.

Entries are appended in the moment, in build order.

---

## 1. No way to get sound without shipping audio files

- **Trying to do:** add shoot / hit / death / wave-start sounds. The project has no audio assets and none are bundled with the engine or templates.
- **What happened:** `@yagejs/audio` is a channel wrapper over `@pixi/sound` — it plays loaded files only. There is no procedural/synth source (oscillator, noise burst) and no starter SFX pack, so I wrote a hand-rolled WebAudio synth module (`src/sfx.ts`, ~100 lines) outside the engine. That module bypasses the engine's channel volumes, mute, and blur auto-pause.
- **Category:** asset gap (with a missing-primitive edge: a tiny "beep/noise burst" synth in `@yagejs/audio` would cover every prototype).

## 2. `setIntensity` is documented as the canonical dial but is not on `EffectHandle`

- **Trying to do:** a chromatic-aberration pulse on player damage — set the effect's strength to 1, tween it back to 0.
- **What happened:** the effects doc (`docs/llms/packages/effects.md`) says "`setIntensity` is the canonical 'how strong is this effect right now' dial", so I wrote `handle.setIntensity(...)`. It does not compile: `setIntensity` lives on the internal `Effect` factory interface, not on the public `EffectHandle` (which only has `fadeIn`/`fadeOut`/`setEnabled`/`run`). Preset extras expose rebase setters (`setSeparation`) that preserve the intensity ratio — there is no public 0..1 dial. Workaround: choreograph `fadeIn(0.05)` + delayed `fadeOut(0.3)` through `handle.run`.
- **Category:** docs ambiguity (secondary: API friction — a public `setIntensity` on the handle is the obvious primitive).

## 3. Quick-start implies `inspector.time` works with `debug: true` alone

- **Trying to do:** verify the game deterministically: `window.__yage__.inspector.time.freeze()` + `step(n)`, as the quick-start's Testing & Debugging section shows right after `new Engine({ debug: true })`.
- **What happened:** at runtime it throws `Inspector.time requires DebugPlugin to be active`. The quick-start example never installs `DebugPlugin`, and the sentence introducing the snippet says only "when the engine is constructed with `debug: true`". Fixed by adding `engine.use(new DebugPlugin())`.
- **Category:** docs ambiguity.

## 4. `collider.getOverlapping()` never fires for resolved contacts (player-vs-enemy contact damage)

- **Trying to do:** contact damage — each frame the player is vulnerable, poll `collider.getOverlapping()` and take damage if any entity tagged `enemy` is in the list.
- **What happened:** silent failure. Both bodies are non-sensor dynamics, so the physics solver resolves the contact before penetration; enemies press against the player at exactly the sum of the two radii (verified via Inspector: distances 27–34 px for radii 12+15) and the overlap query returns nothing, because nothing actually overlaps. No warning anywhere; the game just never dealt damage. The physics doc lists `getOverlapping` right under the collision-events section with no note that it only reports true penetration/sensor overlap. Workaround: per-frame distance check against enemy radii (`onCollision` alone also misses the "enemy already touching when invulnerability ends" case).
- **Category:** docs ambiguity (secondary: recipe gap — "contact damage with i-frames" is a stock top-down pattern that deserves a recipe).

## 5. Inspector synthetic input: `tap()` and `clearAll()` swallow press edges

- **Trying to do:** drive the R-to-restart flow from the Inspector during verification: `inspector.input.tap("KeyR")`, and later `clearAll()` followed by `keyDown("KeyR")`.
- **What happened:** both silently failed to produce an `isJustPressed` edge. `tap()` applies press and release synchronously in one batch, so no stepped frame ever observes the pressed state. `clearAll()` immediately followed by `keyDown` in the same batch also loses the edge; inserting `time.step(1)` between them fixes it. The working pattern is `keyDown` → `step(n)` → `keyUp`, but nothing in the debug docs says `tap` is incompatible with frame stepping.
- **Category:** test-infra gap.

## 6. Synthetic pointer injection bypasses the pointer-consume contract, so overlay touches "leak" only in tests

- **Trying to do:** verify the documented virtual-controls guarantee that a touch on the overlay never fires gameplay actions — driving it with `inspector.input.pointerDown(0, { id, type: "touch" })`.
- **What happened:** every synthetic touch on a stick or button ALSO fired the `fire` action, which reads as the consume contract being broken. It is not: `@yagejs/input` applies synthetic injection synchronously and bypasses the event queue, and the queue drain is exactly where `consumePointer` is honored. Re-testing by dispatching real `PointerEvent`s at the canvas gave the correct result (stick → no actions, button → `dash` only, unclaimed tap → `fire`). Neither the virtual-controls doc nor the input doc's "Synthetic injection bypasses the queue" note warns that consume-dependent behavior is untestable this way, and the failure looks like a product bug rather than a harness limit.
- **Category:** test-infra gap.

## 7. Stick engagement zones are much larger than the drawn control, which invalidates naive test control points

- **Trying to do:** pick a "neutral" spot on screen for a control case that should NOT be claimed by the overlay.
- **What happened:** I picked the middle of the screen (480, 200 of 960x540) and it was silently claimed by the aim stick, producing a confusing zero-action result. The default zone is the bottom 70% of a half-screen — far bigger than the 68px stick that is drawn, and `mode: "follow"` recenters the base under the touch, so there is no visual hint of the zone's true extent. The doc states the fractions but nothing renders or exposes them; a presenter debug flag to outline live zones would have saved the detour.
- **Category:** test-infra gap (secondary: missing primitive — no zone visualization).

## 8. Fixed control set means no restart button on the end screen

- **Trying to do:** let a mobile player restart after death. The overlay is built once with a fixed set ("reconfigure by destroying the host entity and adding a fresh component").
- **What happened:** adding a temporary RESTART button for the game-over state means tearing down and rebuilding the whole `VirtualControls` component mid-scene, which also resets all mirrored input state. I instead made any tap outside the overlay restart on touch devices and relabeled the prompt. Workable, but a per-button `visible`/`enabled` toggle would be the natural fix for state-dependent controls.
- **Category:** API friction.