← all games

action-adventure-fable-max — FRICTION.md

# Friction log — action-adventure-fable-max

Logged in the moment while building. Categories: API friction / missing primitive / recipe gap / docs ambiguity / asset gap / test-infra gap.

---

## 1. llms.txt index omits steering and virtual-controls addon docs

**Doing:** collecting docs for the five required addons before writing code.
**Happened:** `https://yage.dev/llms.txt` lists only three addons (dialogue, inventory, abilities). `@yagejs-addons/steering` and `@yagejs-addons/virtual-controls` install fine from npm and their docs actually exist at the predictable URLs (`llms/addons/steering.md`, `llms/addons/virtual-controls.md`) — they are just missing from the index. An agent that trusts the index would conclude the docs don't exist and fall back to reading package source.
**Category:** docs ambiguity

## 2. Renderer doc's defaultTextStyle example doesn't typecheck

**Doing:** setting an engine-wide default text style in `RendererPlugin` per
`packages/renderer.md`, which shows
`defaultTextStyle: { fontFamily, fill, resolution }`.
**Happened:** `resolution` is rejected by the published types —
`defaultTextStyle` is `TextStyleOptions`, and the same doc elsewhere states
resolution is a Text *constructor* option, not a style property. The example
contradicts the doc's own gotcha two sections down. Dropped `resolution` and
moved on; crisp text needs per-text `resolution` or `bitmap`.
**Category:** docs ambiguity

## 3. create-yage "recommended" template ships a platformer with assets

**Doing:** scaffolding a top-down, zero-asset game with
`create-yage --template recommended --features ui,effects`.
**Happened:** the scaffold is a gravity platformer seed with sprite/audio
files under `public/assets/` and platformer-tuned physics
(`gravity: 980`). For a top-down game every gameplay file plus the asset
pack had to be deleted and gravity zeroed. Fine for a demo, but a
`--template blank` (engine boot + empty scene, no assets) would fit
agent-driven builds better. Also: `@yagejs/particles` is not offered in
`--features` even though effects is, so it's a separate install step.
**Category:** recipe gap

## 4. Frozen-step probing reads the dialogue runner mid-await and manufactures convincing fake bugs

**Doing:** verifying NPC conversations under the recommended headless pattern
(`inspector.time.freeze()` + `step(n)` + synthetic input), because the
embedded preview tab suspends requestAnimationFrame.
**Happened:** the dialogue runner advances through `async` chains
(`advanceLine` awaits `fireLineCommands`, `play` awaits its first
present), so any state read in the same synchronous probe turn sees the
runner suspended mid-microtask. That produced three completely convincing
phantom bugs before the cause surfaced: (1) "a `goto` as the first step ends
the conversation" — `isActive()` false right after `play()`; (2) "keyboard
advance is broken" — `session.advance()` suspends on its await, `advancing`
stays latched, later presses no-op; (3) "choices soft-lock" — `choose()`
refused while the state I was reading was stale. All three vanish once every
input/step batch is followed by a real macrotask yield
(`await new Promise(r => setTimeout(r, 0))`). The skill's gotcha list warns
about this for SceneManager transitions only; it equally applies to any
addon with an async pipeline, and nothing in the addon surfaces "I have
pending microtasks" to a prober. Cost: ~40 minutes chasing phantoms, plus a
temporary in-game workaround that had to be reverted.
**Category:** test-infra gap

## 5. A throw inside an onTrigger callback is swallowed silently and leaves half-applied state

**Doing:** door pads as static sensors whose `onTrigger` callback starts the
zone transition. The transition had a bug of mine (it resolved a
ProcessComponent the darkness entity didn't have) and threw.
**Happened:** nothing surfaced anywhere — no console error, nothing in
`Inspector.getErrors()` (the ErrorBoundary covers component/system updates,
not physics event callbacks). The throw aborted the handler mid-way, leaving
`PlayerController.frozen = true`, so the observable symptom was "the player
permanently stops moving, sometimes", far from the cause. The same buggy
code moved into a polled component produced a visible
`disabledComponents: [{ entity: "Door", component: "DoorWatch", error:
"Entity \"darkness\" does not have component ProcessComponent" }]` in one
probe. Cost: this masqueraded as a sensor-events engine bug for ~30 minutes
(sensor enter events work fine — verified with a direct listener). Trigger
callbacks deserve the same error-boundary treatment as component updates.
Kept the polled version: identical behavior, strictly better diagnostics.
**Category:** API friction

## 6. No single "is a conversation blocking gameplay" read; the event-driven mirror desyncs

**Doing:** gating movement and abilities on a `uiBlocked` flag that a
game-side listener flips from `DialogueStartedEvent` /
`DialogueEndedEvent` + the `onEnded` callback — the composition the dialogue
doc demonstrates for host-owned focus.
**Happened:** the flag stuck `true` after a conversation was ended by a
`controller.stop()` rather than an in-script `end` step. `stop()` resets the
session but the ended-event path my mirror listened on didn't run, so the
world stayed frozen — no movement, no abilities, a total soft-lock — while
`isActive()` correctly reported false. The addon has `isActive()` (and the
levers `setPaused`/`setInputEnabled`) but no "does this instance currently own
player focus" read, so every game re-derives one from events and inherits this
desync class. Fix: stop mirroring events and set `state.dialogueOpen =
controller.isActive()` once per frame in the scene tick — authoritative,
can't drift. Worth surfacing a `blocksGameplay`-style getter, or at least
documenting "derive per-frame from isActive(), don't mirror the events."
**Category:** API friction

## 7. A stale Vite module survives a dev-server restart and fakes a spawn-time crash

**Doing:** final zero-error check in the long-lived preview tab after a
sensor-polling refactor that added a helper component
(`class PlateWatch` declared below the entity that instantiates it).
**Happened:** the tab threw `ReferenceError: PlateWatch is not defined` from
`TidePlate.setup()` on every reload — including after `preview_stop` +
`preview_start` (a genuinely new server process) and a
`window.location.href` navigation. The module URL stayed pinned to one frozen
`?t=1784743346216` transform timestamp the whole time. `curl` of the same
path from the dev server returned the correct, fresh module, and
`vite build` was clean — so the source was never wrong; the browser tab was
serving a cached transform whose import graph still pointed at the broken
version, and neither a server restart nor a same-tab navigation busted it.
Only opening a brand-new tab pulled the fresh graph (zero errors, all
entities present). Two engine behaviors amplified the confusion: a throw in
`Entity.setup()` silently destroys that entity and leaves
`Inspector.getErrors()` empty (documented in the skill), and
`read_console_messages` replayed the stale errors. This is the HMR-corruption
gotcha the skill warns about, but the missing detail is that it can outlive a
dev-server restart — the reliable reset is a fresh browser tab, not a reload.
Also hardened the code: every setup-instantiated helper component now
declares-before-use, so a reordered/partial module can't reintroduce this.
**Category:** test-infra gap