← all games

arena-shooter-fable-max — FRICTION.md

# Friction log — arena-shooter-fable-max

DX findings from building this game, recorded in the moment. Categories:
API friction / missing primitive / recipe gap / docs ambiguity / asset gap / test-infra gap.

---

## 1. Steering addon recommended but undocumented

- **Trying to do:** Plan enemy chase behavior. The shooter design checklist (yage-game-developer skill) maps "enemy chase/flee movement" to `@yagejs-addons/steering`.
- **What happened:** The package exists on npm (v0.1.0) but has no doc page under `https://yage.dev/llms/packages/` — probed `steering`, `addons-steering`, `yage-addons-steering`, `steering-addon`; every URL returns 404. The LLM docs give no way to learn its API; the only routes are package source or the shipped types. I hand-rolled seek + separation + wobble steering instead.
- **Category:** docs ambiguity

## 2. `--features ui,effects` installs deps but gives no wiring guidance

- **Trying to do:** Scaffold with UI and effects included: `npx create-yage arena-shooter-fable-max --template minimal --features ui,effects --yes`.
- **What happened:** `package.json` gained `@yagejs/ui`, `@yagejs/ui-react`, `react`, `react-dom`, `@yagejs/effects` — but no wiring, no example, and no comment block anywhere in the scaffold. `grep -rn "effects|ui-react|@yagejs/ui" src/ index.html vite.config.ts` returns zero hits. `src/main.ts` has copy-paste-ready commented blocks for physics, input, audio, and debug, but nothing for the two features I explicitly requested. So the flag installs code I then have to learn to connect from external docs, with no in-project hint.
- **Category:** recipe gap

## 3. Scene RNG is advertised but `RandomKey` is undocumented

- **Trying to do:** Use the deterministic scene RNG for spawn positions and drop rolls, so `inspector.setSeed(42)` replays work as the quick-start promises ("pin every scene RNG (for replays)").
- **What happened:** No LLM doc says how game code *reads* that RNG. `grep -in "random"` over `core.md` returns only `QueryCache.queryOnce` lines; `quick-start.md` and `patterns.md` never mention it either. The answer — `RandomKey`, a scene-scoped `ServiceKey<RandomService>` with `float/range/int/pick/shuffle`, plus `globalRandom` for boot-time code — exists only in `node_modules/@yagejs/core/dist/index.d.ts:1130`. Without reading shipped types I would have used `Math.random()` and silently broken the advertised determinism.
- **Category:** docs ambiguity

## 4. No audio without asset files — synth hand-rolled outside the engine

- **Trying to do:** Give the shooter the audio the genre checklist demands (distinct fire / hit / death / pickup / damage / wave cues).
- **What happened:** `@yagejs/audio` only plays loaded files (`sound("assets/coin.wav")`); there is no synthesis or "beep" primitive, the scaffold ships zero audio assets, and no YAGE asset pack is available to a fresh project. I wrote a ~200-line WebAudio synth (`src/sfx.ts`) entirely outside the engine — which means it also loses the engine's channel volumes, mute, and blur auto-pause integration.
- **Category:** asset gap (with a missing-primitive edge: a tiny built-in synth or a default SFX pack would close it)

## 5. Docs contradict each other on tween time units

- **Trying to do:** Write the wave-banner cascade with `Tween.stagger` + `Tween.to`.
- **What happened:** Three docs give three stories. `core.md` names the stagger param `stepMs`; `ui.md`'s React example passes `Tween.to(c, "alpha", 1, 300), 50` (millisecond-scale numbers); `renderer.md`'s SplitText example passes `0.3, 0.05` (seconds); `patterns.md` mixes both (`Process.delay(1, ...)` seconds two lines above `Tween.to(transform, "rotation", Math.PI, 500, ...)`). Shipped types settle it — `stepSeconds: number` in `dist/index.d.ts:2634`, durations in seconds — but only after I stopped to check. An agent trusting `ui.md` writes tweens that run 300× too long.
- **Category:** docs ambiguity

## 6. Documented tween patterns fail against the real `Transform` type

- **Trying to do:** Rise-and-fade score popups and per-glyph banner cascades using the documented patterns `Tween.to(transform, "rotation", Math.PI, 500, easing)` (patterns.md) and `setter: (v) => (entity.get(Transform).y = v)` (core.md's KeyframeAnimator example).
- **What happened:** Both doc examples are wrong against the shipped API. (a) `Tween.to` is typed `to(target: Record<string, number>, ...)`, which rejects every class instance — `Transform`, Pixi `Text` — under the scaffold's own strict tsconfig, so the patterns.md example cannot compile in a create-yage project; the workaround is `Tween.custom` with a setter closure. (b) `Transform` has no `y` accessor at all (`position` is an immutable `Vec2`; mutation goes through `setPosition`/`translate`), so core.md's `transform.y = v` example would silently write a dead expando property and animate nothing.
- **Category:** docs ambiguity / API friction (the `Tween.to` typing itself makes the most natural call unusable on engine objects)

## 7. `SplitTextComponent` cannot be centered on a point

- **Trying to do:** Center the menu title and the wave banners ("WAVE 2") on the screen midline.
- **What happened:** `TextComponent` takes `anchor`, but `SplitTextComponent` does not — its `charAnchor` / `wordAnchor` / `lineAnchor` are position-compensated *pivots* for animating segments, not block alignment (setting `lineAnchor: 0.5` changed nothing visually; the block's top-left sits at the Transform, so the title rendered half off-screen). The fix is manual: read the underlying Pixi object via the `splitText` escape hatch, measure `width`/`height` after first layout, and offset the Transform by half (`CenterSplitText` in `src/fx.ts`). A block-level `anchor` option matching `TextComponent` would remove the whole dance.
- **Category:** API friction / missing primitive

## 8. `getOverlapping()` semantics are undocumented — resting contacts return nothing

- **Trying to do:** Player contact damage that also re-fires when an enemy keeps pressing against the player after i-frames expire. `onCollision` only reports `started` edges, so I reached for the documented overlap query: `collider.getOverlapping()`.
- **What happened:** physics.md documents the call in one line ("Overlap queries") with no semantics. Empirically, two solid circles resting in contact (center distance exactly r1+r2, which is what Rapier's solver produces — solid bodies touch, they don't penetrate) return an empty array, so the poll never fired and the player was unkillable. Nothing in the docs says whether the query means "penetrating", "touching", or "sensor-intersecting". Gave up on the physics API and wrote a plain circle-distance check.
- **Category:** docs ambiguity (borderline API friction — a contact-damage query is the single most common need in this genre)

## 9. UI overflow warning names no node

- **Trying to do:** Fix a `[yage] UI layout: a child overflows its container by 2.0px past the right edge` warning appearing on boot.
- **What happened:** The warning fires "once per node" but includes nothing to identify the node — no entity name, no element type, no text content. With ~20 UI elements across four HUD cards and a controls panel, locating a 2px overflow means bisecting by hand. The renderer's undeclared-layer warning shows the right shape (it names the entity, the layer, and the scene); this one should too. The 2px overflow is still unlocated; the warning remains in this project's dev console.
- **Category:** test-infra gap (diagnosability of the dev warning)

## 10. `addExtension` throws on re-register; failure mode is a silently gutted scene

- **Trying to do:** Re-register the `arena` debug extension when a fresh `GameScene` spawns its `WaveDirector` (RETRY replaces the scene, so the component re-runs `onAdd`).
- **What happened:** `Inspector.addExtension` throws on a duplicate namespace. debug.md's example registers from a plugin (lives once, forever), never mentions the throw, and never says a scene-owned component must `removeExtension` on teardown. The blast radius is what makes it a trap: the throw happened inside `Entity.setup()` → `scene.spawn` destroys the entity and rethrows (documented) → `onEnter` aborts mid-way → the rejection disappears into the async scene-op chain (ErrorBoundary only covers update/fixedUpdate, and `getErrors()` stays empty). Result: the retried run boots looking normal but has no wave director — no banner, no enemies, no diagnostics anywhere. Found only because a scripted Inspector probe noticed the entity count differed from the first run.
- **Category:** docs ambiguity (plus a test-infra gap: exceptions thrown during `onEnter` from a queued scene op surface nowhere)

## 11. Frozen-clock stepping cannot complete async scene operations

- **Trying to do:** Drive the whole game deterministically from Inspector probes, per debug.md's "throwaway spec" workflow: freeze, inject input, `time.step(N)`, assert.
- **What happened:** `SceneManager` push/replace are async and their `await` chain resolves in microtasks — which never flush inside a synchronous `step(N)` loop. So "tap Enter, step 45 frames, read the new scene's state" fails: the transition needs both frames AND event-loop turns, interleaved. Symptoms are confusing (extension `undefined`, stack still showing the old scene) until you know to sprinkle `await new Promise(r => setTimeout(r, ...))` between step batches. debug.md's agent-driven-debugging section never mentions this; a `step` variant that yields to the microtask queue (or an async `stepUntil(predicate)`) would make scene transitions probeable without folklore.
- **Category:** test-infra gap

## 12. `burst()` in the emitter's creation tick emits at world origin

- **Trying to do:** One-shot particle bursts (muzzle flash, impact sparks, death explosions): spawn a carrier entity with `Transform` + `ParticleEmitterComponent`, call `.burst(n)` immediately.
- **What happened:** Every burst's first-frame particles painted at the top-left corner of the arena — world (0,0) — because the emitter's Pixi container has never been transform-synced when `burst()` runs in the same tick the component was added. Confirmed by freezing the clock on a spawn frame: the bullet's own `GraphicsComponent` was already synced to the muzzle (the renderer covers brand-new visual components in the same frame's Render phase), but the burst particles sat at origin. Player-visible result: a phantom flash in the corner on every shot. The fix is the positional overload — `burst(count, worldX, worldY)` — which stamps world coordinates and sidesteps the race entirely; particles.md documents it as "burst at position" without hinting that the no-args form is unsafe on a freshly created emitter. Either `burst()` should fall back to the entity's Transform when the container is unsynced, or the doc needs a "same-tick burst" caveat.
- **Category:** API friction (particles plugin syncs its container later than the renderer syncs visual components; the natural first call hits the gap)

## 13. Synthetic `mouseUp` resets the pointer to (0,0)

- **Trying to do:** Diagnose the above with scripted input: `pointerMove(x, y)` → `mouseDown(0)` → step → `mouseUp(0)` → screenshot.
- **What happened:** After `inspector.input.mouseUp(0)`, `getPointerPosition()` reports (0,0) — the synthetic pointer's position does not survive the release. Everything that reads the pointer (the crosshair, the ship's aim) snapped to the world origin, which manufactured a convincing decoy lead ("the crosshair flashes at top-left") and even redirected test shots toward the corner. A real mouse never does this, so the quirk only burns people writing Inspector probes — but it burns them exactly when they are chasing a position bug, the worst possible moment for the input layer to fabricate coordinates.
- **Category:** test-infra gap

## 14. `@yagejs/ui` re-lays-out and re-tessellates every panel every frame — the dominant GC driver

- **Trying to do:** Explain reported major GC events during play. Measured with Playwright + CDP (the embedded preview page reports `visibilityState: "hidden"`, so rAF never fires there and real-time profiling needs a real foreground browser).
- **What happened:** Combat with ~10 enemies allocates **~7.5–9 MB/s**, triggering **~85 GC collections/minute** (12–13 drops per 9 s window, ~66 MB reclaimed, largest drop 5.6 MB). A V8 sampling allocation profile (`includeObjectsCollectedByMinorGC/MajorGC`) attributes **~58% of all allocation to Pixi `GraphicsContext` re-tessellation** (`triangulate` 23.6%, `build` 11.6%, context rebuild 13.1%, `fill`/`roundRect`/`updateGpuContext` the rest) — and the menu alone, zero gameplay, shows the same signature at 4.5 MB/s. Source of the storm, from `@yagejs/ui` dist: `UILayoutSystem.update()` runs every frame (`Phase.LateUpdate`) and unconditionally calls `yogaNode.calculateLayout(...)` + `applyLayout()` on every `UISurface` — no dirty flag; `UIPanel.applyLayout()` unconditionally calls `bgRenderer.resize(w, h)`; and `BackgroundRenderer.resize` redraws the background Graphics **without comparing against `lastWidth`/`lastHeight`** (the fields exist but are never checked), so every color/radius background re-enters Pixi's clear → roundRect → fill → triangulate pipeline every frame even when nothing changed. `warnChildOverflow` also runs (and allocates ~0.2 MB/s) per frame per surface. A same-size early-return in `BackgroundRenderer.resize` — two lines — would eliminate the majority of the game's entire GC load; layout dirty-gating would finish the job.
- **Category:** API friction (engine performance bug candidate; PR-sized fix in `@yagejs/ui`)

## 15. Engine frame loop has a ~2–3 MB/s allocation floor; no performance guidance in docs

- **Trying to do:** Attribute the remaining allocation after the UI share.
- **What happened:** The same profiles show **~1.5–1.9 MB/s from Rapier's wasm-bindgen glue** (`fromWireType` + anonymous wrapper allocations in the physics chunk — every `linvel()`/body read allocates a wrapper object, and `RigidBodyComponent.getVelocity()` adds a `new Vec2` on top; the world steps and allocates even in the menu with zero bodies), plus ~1 MB/s of Pixi filter/render-pass overhead (four filter instances: two blooms, vignette, chromatic aberration) and ~0.13 MB/s in the renderer's `applyCameraTransforms`. Game-code steering (immutable `Vec2` math, `findEntitiesByTag` arrays per enemy per frame) did not crack the top 26 allocators — under ~3% combined, so the "obvious" gameplay suspects are noise. Nothing in the LLM docs covers allocation behavior, pooling, or which APIs allocate per call; retained heap was flat (~0.1 MB survived 8 s), so this is pure churn, not a leak.
- **Category:** docs ambiguity / missing primitive (allocation-free math + physics read paths, a pooling recipe, and a perf page would all help)

## 16. Entity churn is expensive per unit, and `debug: true` taxes it further

- **Trying to do:** Quantify what shooting and killing add to the GC load (player-reported major GC spikes during heavy fire). Real-input profile: 51 shots + 3 kills over 10 s.
- **What happened:** Sustained fire raises allocation from ~7.5 to **~10 MB/s**, and the largest GC drop doubles (5.6 → 11.9 MB). The delta works out to roughly **~100–150 KB of dead objects per bullet lifecycle** (entity + components + Rapier body/collider create-destroy + a fresh Pixi `Graphics` whose geometry is built and thrown away ~0.9 s later) and **~0.5–1 MB per kill** (two one-shot particle emitters with their own pools and containers, a ring `Graphics`, a rasterized `Text` popup at resolution 2, sound-buffer allocation, event records). Three engine-level lines stand out in the profile: `recordBusEvent` at **5.2%** — with `debug: true` the Inspector's event log allocates an entry for every `entity:created`/`component:added`/`destroyed`, so entity churn is taxed once more (the scaffold boots with `debug: true`, so every dev build pays this); `@yagejs/particles` `_update` at ~0.3 MB/s despite the "pooled emitters" billing; and `FinalizationRegistry.register` churn (~1.5 MB/10 s) under object turnover. These medium-lived objects (0.2–0.9 s) are exactly the population that survives minor GCs, gets promoted, and turns into old-generation garbage — the raw material for the multi-hundred-ms major collections reported during long kill-heavy sessions. No pooling primitive or recycling recipe exists in the engine or docs; every projectile-heavy YAGE game will rediscover this.
- **Category:** missing primitive (entity/graphics pooling) + API friction (debug-mode event recorder cost is invisible and unconditional)