← all games
rampage-fable-high — FRICTION.md
# Friction log — rampage-fable-high (Eyewall)
Run: top-down "you are the storm" spectacle action, YAGE 0.10.4 from the local
tree (`../yage-local` at commit 0bc41ac6, linked by path), addons steering
0.3.0, feel 0.1.0, synth 0.1.0, virtual-controls 0.2.0, lab 0.1.1. Art from
`../yage-assets` (Tiny Swords, Kenney particles, library fonts and music).
Logged in build order. Categories: API friction / missing primitive / recipe
gap / docs ambiguity / asset gap / infra-test gap.
---
## 1. Linking engine packages by path installs a second pixi.js, and linking pixi runs husky
**Trying to do:** depend on the local engine tree with `file:` links (the
observatory's convention) while importing `pixi.js` in game code for
`Container`, `Sprite`, `Graphics` and `Texture`, which the render-target doc
example and the texture-region cutting need.
**What happened:** npm installed `pixi.js@8.20.1` under the game while the
linked `@yagejs/renderer` resolves `pixi.js@8.17.1` from the engine tree
(symlinks resolve to their real path). Two Pixi runtimes break `instanceof`
and the type checker reports every Pixi type as two incompatible types. The
fix that worked: `"pixi.js": "file:../../../yage-local/node_modules/pixi.js"`
in `package.json`, installed with `--ignore-scripts` because pixi's own
`prepare` script runs husky on a `file:` install and fails. A published
install would not hit this, but the documented way to test an unreleased
engine version does, and nothing in the docs mentions it.
**Category:** infra-test gap
## 2. Joints are rope and spring only
**Trying to do:** breakable buildings from jointed pieces that snap under load.
**What happened:** `physics.md` lists `rope` and `spring`. There is no fixed,
revolute or prismatic joint and no break threshold, so a jointed structure is
not expressible. Buildings are pre-cut into pieces that switch from a static
sprite to dynamic debris, which the package supports well through
`EntityPool` and `setType`. A gap, not a blocker.
**Category:** missing primitive
## 3. `@playwright/test ^1.60` resolves past the browsers on the machine
**Trying to do:** run `yage-lab test` and Playwright probe scripts.
**What happened:** the lab declares `@playwright/test ^1.60.0`; npm resolved
1.63.0, whose chromium build was not installed, so every launch failed with
"Executable doesn't exist" until the game pinned `@playwright/test@1.60.0`
to match the engine tree. Minor, but it costs a download or a pin on every
fresh game.
**Category:** infra-test gap
## 4. An `Entity` subclass has no destroy hook, so resource ownership needs a component
**Trying to do:** bake the ground into an offscreen render target inside
`Ground.setup()` and free the texture and the registered key when the entity
goes away.
**What happened:** `Entity` has no `onDestroy`; only components have one
(`core/src/Entity.ts`, `_performDestroy` calls component hooks only). The
render target, its source container and the texture key therefore live in a
small `BakedTexture` component whose `onDestroy` frees them. That is
consistent with "components own resources", but the render-target doc shows
the allocation inside scene code with no owner, so the first draft leaked.
**Category:** API friction
## 5. Scene-scoped services are awkward to reach from an entity's `setup()`
**Trying to do:** read the scene RNG (`RandomKey`) and the lighting world
(`LightingWorldKey`) from an entity's `setup()` and from a scene helper
function.
**What happened:** `Scene.use()` is protected, so entity code cannot call
`this.scene.use(RandomKey)`; the public route is
`scene.tryResolveScoped(key)` plus a throw when it returns `undefined`. The
docs mention `tryResolveScoped` only in the context of systems iterating
scenes. A public `Scene.resolveScoped(key)` that throws would remove the
boilerplate.
**Category:** API friction
## 6. `spawnChild` runs the child's `setup()` before the parent link exists
**Trying to do:** have the funnel child read its parent's `StormStats` in
`onAdd()`.
**What happened:** `entity.spawnChild(name, Class)` is documented as
`scene.spawn(...)` plus `addChild(...)`, and that order means `entity.parent`
is `null` during the child's `setup()` and its components' `onAdd()`. The
throw surfaced as a callback error and the child was destroyed. Every
parent-reading child in this game resolves the parent lazily on its first
`update()`. Worth one line in the `spawnChild` docs.
**Category:** docs ambiguity
## 7. A `createTexture` result clamps, so a texture fill larger than the texture is empty
**Trying to do:** fill the funnel cone with a scrolling band texture via
`g.fill({ texture, matrix, textureSpace: "global" })`.
**What happened:** the fill rendered only inside one texture-sized window;
the rest of the cone was transparent. Pixi's default address mode is
clamp-to-edge, so the tiling needs
`texture.source.addressMode = "repeat"`. The renderer doc covers
`createTexture` and gradient fills but not texture fills or wrap modes, and
the funnel is exactly the case where a tiled fill is wanted.
**Category:** docs ambiguity
## 8. Effects have no scope for "every world layer but not the UI"
**Trying to do:** a bulge-pinch lens that follows the eye and a color grade
per storm form, over the whole world but not the HUD.
**What happened:** the four scopes are component, layer, scene and screen.
Scene and screen scope filter the UI layer too (documented). Layer scope
would mean attaching the same filter to seven world layers, each a separate
render pass. The form color grade stays at scene scope, so it tints the HUD
too; the distortion moved to two ground layers, which also keeps the funnel
and units straight. A "night" color grade at scene scope tinted the HUD
blue and had to go; the lighting overlay handles night instead. A scope that
covers the world-space layers as a group, or a way to exclude a layer from a
scene-scope effect, would close this.
**Category:** missing primitive
## 9. `queryRadius` takes packed interaction groups, and the doc does not say how to build them
**Trying to do:** find units near the eye during a surge and around a
lightning strike with `world.queryRadius(center, r, { filterGroups })`.
**What happened:** `filterGroups` is a single number. Passing a layer bit
from `CollisionLayers.define` matches nothing useful; the value is Rapier's
packed `membership << 16 | filter`, which the `CollisionLayers.interactionGroups(membership, filter)`
helper builds. The physics doc lists the option without linking the two, so
the first attempt was `filterGroups: COL.unit`. A one-line example
(`interactionGroups(0xffff, LAYER_UNIT)` to hit every unit collider) would
prevent the misread.
**Category:** docs ambiguity
## 10. Camera bindings are constructor-only, so one parallax layer means listing every layer
**Trying to do:** give the cloud-shadow layer a translate ratio above 1 while
every other layer keeps the default.
**What happened:** `CameraComponent.bindings` is readonly after construction
and passing any `bindings` disables auto-binding, so the scene spawns the
camera with an explicit binding for all seven layers just to change one.
A per-layer override merged with auto-bind (`bindings: { sky: { translateRatio: 1.18 } }`)
would be the obvious shape.
**Category:** API friction
## 11. A pooled sprite needs a real texture key before it has anything to show
**Trying to do:** prewarm 32 debris entities whose texture is decided at
`onAcquire`.
**What happened:** `SpriteComponent` requires a `texture` at construction and
throws on a key that is not loaded or registered, so dormant pool members
carry a placeholder bush texture until `setTexture` replaces it. Harmless,
but an `Optional`/blank texture would express the intent.
**Category:** API friction
## 12. `getComponentData` is by entity name, so same-named entities cannot be inspected individually
**Trying to do:** check the light position of one of 14 `Lantern` entities
while debugging why the night looked unlit.
**What happened:** `Inspector.getComponentData(entityName, componentClass)`
returns the first active entity with that name; `getEntities()` returns ids
but there is no id-addressed component read. Snapshots do include every
entity's component state, so the data is reachable, just not through the
convenient call.
**Category:** infra-test gap
## 13. No wizard or mage unit, no cart or windmill prop, no lightning sprite in the library
**Trying to do:** cast the wind mages and the Archmage, dress farmland with
carts and a windmill, draw lightning for the Thunderhead form.
**What happened:** the Tiny Swords set covers warriors, archers, workers,
goblins, sheep, buildings and resources, but no caster. The mages are the
blue worker recolored teal and violet with the `colorize` effect, which reads
acceptably. Windmills and carts were dropped from the node dressing.
Lightning is drawn with `GraphicsComponent`. The `colorize` effect turned
out to be a good substitute for a missing palette swap.
**Category:** asset gap
## 14. `bulgePinch.center` drifts with resolution and window size
**Trying to do:** keep a lens distortion centered on the eye.
**What happened:** the doc says `center` is "normalized 0..1 screen coords"
and lists the preset among the resolution-stable ones. At renderer
resolution 1 the center is right, letterbox bars included. At resolution 2
(a Retina display) the distortion lands past the eye by an offset that grows
with the eye's distance from the top-left corner and changes with the window
size, so it leaves the screen on the far side of a node. The cause is in the
wrapped pixi-filters `BulgePinchFilter`: its shader computes
`uv * uInputSize - center * uDimensions`, and the two sizes are not the same
rectangle once the input texture is resolution-scaled. A human playtest on a
Retina display found it; every scripted run had used pixel ratio 1.
The game switched to the engine's own `implosion` preset, whose center is in
host-local pixels and converts units in the shader; it is exact under both
conditions and, with a small swirl, reads more like a vortex anyway. The
doc's resolution-stability list should drop `bulgePinch`, or the preset
should set `uDimensions` from the same frame the shader's `uInputSize` uses.
**Category:** API friction (engine bug in a wrapped filter)
## 15. `shockwave` only expands, and its handle hides the filter's clock
**Trying to do:** a slow, repeating ring that contracts toward the eye, as
an alternative to the vortex distortion.
**What happened:** the preset's handle offers `trigger(x, y)`, which ramps
the wrapped filter's `time` from zero, and nothing else; `EffectHandle`
exposes no filter or time. A contracting ring needs `time` driven
backwards, which means subclassing the pixi-filters `ShockwaveFilter` in
game code and repeating the preset's own unit conversion. The game uses a
pulsing `implosion` radius instead. A `direction` or `setTime` on the
shockwave handle would cover it.
**Category:** API friction
## 16. `UIImage` with only a `height` stretches the texture
**Trying to do:** show almanac art in a card at a fixed height with the
width following the texture's aspect.
**What happened:** `UIImage` keeps the aspect only when the width is
constrained. Its measure function starts from the texture's own width, and
with `height` alone the height mode is "exactly" while the width mode is
undefined, so a 192 px square frame comes out 192 wide and 58 tall. The
fix in the game measures the texture through `resolveTextureInput` and
passes both `width` and `height`. A `fit: "contain"` option, or an
aspect-preserving measure for the height-only case, would remove the
helper.
**Category:** API friction
## 17. Does a down+up pair inside one frame count as a press? The docs do not say
**Trying to do:** drive menus from Playwright with `page.keyboard.press`,
which sends keydown and keyup back to back.
**What happened:** one scripted run missed an Escape in the almanac and the
first guess was that a pair landing inside a single frame is dropped. It is
not: `InputManager` queues DOM events and drains them at early update, a
keyup never removes the action from the frame's press set, and
`InputManager.test.ts` covers the pointer analogue ("same-frame DOM
pointerdown+pointerup still fires MouseLeft press/release edges"). Six
replays of the failing script all popped the scene; the miss was not
reproduced and its cause is unknown. What remains is a doc gap:
`input.md`'s frame-deferral section diagrams a single event and never states
that a same-frame pair reports both edges, so a scripted-run author has to
read the source to trust `press`.
**Category:** docs ambiguity (minor; the engine behavior is right)
## 18. `AnimatedSpriteComponent` speed changes only through `play()`
**Trying to do:** speed a sheep's walk cycle up as it panics.
**What happened:** there is no speed accessor on the component; the only
way to change it is `play({ speed })`, which resumes from the current frame
(so it works) but reads as "restart" and has to be rate-limited by the
caller to avoid re-issuing every frame. A `speed` setter, or documenting
that `play` is the setter, would make it obvious.
**Category:** API friction
## 19. The easing set stops at five functions
**Trying to do:** pop the end-of-node banner in with an overshoot.
**What happened:** `@yagejs/core` exports `easeLinear`, `easeInQuad`,
`easeOutQuad`, `easeInOutQuad` and `easeOutBounce`. No back, elastic,
cubic or expo, so a UI pop uses bounce or a hand-rolled curve.
**Category:** missing primitive
## 20. `yage-lab test` fails when port 5173 is busy, despite asking for "any free port"
**Trying to do:** run the lab gate while another project's Vite dev server
holds 5173.
**What happened:** the lab creates its test server with `server.port: 0`,
commented as "any free port". Vite 8 treats 0 as unset and falls back to
5173; the game's `vite.config.ts` sets `strictPort: true` for its own 5199,
so the merged config refuses 5173 and the gate exits with "Port 5173 is
already in use". `yage-lab test` also rejects `--port`. The game now turns
`strictPort` off when it detects the lab CLI in `process.argv`. The lab
should pick a genuinely free port (`get-port`, or `strictPort: false` in its
own merge) so a gate never depends on what else is running.
**Category:** infra-test gap
## 21. No wall, palisade or fence art in the library
**Trying to do:** rock walls and palisades that hold the storm out of a
compound until it grows, and can be torn tile by tile.
**What happened:** the catalog has no top-down wall, palisade, fence or
gate art for the Tiny Swords style (the only "wall" hits are cyberpunk
props, dungeon doors and a sci-fi barrier). The game builds its walls from
the elevation cliff tileset (`tiles-rts-cliff`: plateau cap plus one row of
cliff face), which reads as raised rock rather than masonry. A wooden
palisade with corner posts and a gate, and a stone wall with crenellations,
in the same set would let a kingdom look walled instead of quarried.
**Category:** asset gap
## 22. Font metadata does not say which glyphs a font has
**Trying to do:** mark a done objective in the HUD with a check mark, and
show a held upgrade as "x2".
**What happened:** nothing in `CATALOG.md` or the per-font
`*.meta.json` records glyph coverage, and a `.ttf` cannot be read by
grep, so an agent picking a font cannot tell what it will render. The
guess costs either a wrong character or a needless retreat to ASCII.
Parsing the cmap tables directly settled it: `font-not-jam-ui-condensed-16`
and `font-not-jam-bore-blasters-16` carry 126 glyphs each — ASCII plus a
handful, with no check mark, ballot X, bullet, arrow, multiplication sign,
em dash or accented letter (the middle dot is there);
`font-dungeon-mode` carries 332 and adds the bullet, arrow and Latin-1
accents but still no check mark. This game had shipped `Held ×2` in the
debrief, which the UI font cannot draw. A `glyphs` field in the metadata
(a count, the covered ranges, and a flag for the dozen symbols UI text
reaches for) would make the choice checkable, and would tell an agent
whether a font can carry a language before it is picked for one.
**Category:** asset gap
## 23. The engine parts that gave no friction, for the record
Worth stating so the log does not read as all negative:
- `EntityPool` with `forceAcquire` and `reclaimPriority` carried 80 physics
debris with no destroy-time crash.
- `LayerDef.sort: ySort` plus a `SortGroupComponent` per building made
debris sort in front of and behind the funnel correctly with no extra code.
- The visual modifier API (`modifiers.addTransform`) lifted debris sprites
above their ground position without touching physics.
- Offscreen render targets did both jobs: a one-shot bake of the ground and
a two-buffer feedback trail that survives camera movement.
- `feel` cues, `synth` sounds, the lighting overlay, `implosion`, `bloom`,
`colorize`, `hitFlash`, `glow` and `vignette` all worked from their docs on
the first try.
- `yage-lab` scenarios rebuilt the scene per control change and the driven
tests ran green in headless chromium without configuration.
- The four findings this run sent upstream came back and were used the same
day: `tree.addLayerEffect` scopes the form's colour grade to the world
layers so the HUD keeps its own colours, `Scene.use` removed six
throw-if-undefined blocks, camera `bindings` now override the auto-bound
layers instead of replacing them (one line for the parallax sky), and
`Inspector.getEntity` addresses entities by id in the probe tools.
- The minimap needed the ground's baked render target a second time, from
another entity, and the registered texture key was all it took: no
re-bake, no second target.
---
## 24. A particle emitter's rate is fixed at construction
**Trying to do:** ramp the storm's dust skirt and a "gathering motes" emitter
up over the two seconds of the summon, so the ground starts still and ends
with debris rising.
**What happened:** `EmitterConfig` is taken by the constructor and stored on a
`private config`, and the component exposes no rate setter — only `emit()`,
`stop()`, `requestEmission()` and `burst(count)`. `emitter.config.rate = r`
is a TypeScript error. Continuous emission is therefore on or off.
**Worked around it** two different ways in one pass, neither of them good.
The dust skirt became a threshold: `stop()` below 45% of the summon,
`emit()` above it, so the ramp is a step. The gather emitter drives its own
rate with an accumulator:
```ts
this.motes += GATHER_RATE * gathering * dt;
const whole = Math.floor(this.motes);
if (whole > 0) {
this.gather.burst(whole);
this.motes -= whole;
}
```
That is the emitter's own spawn loop, re-implemented in the caller, and it
loses the emitter's sub-frame spacing. A settable `rate` (or
`setRate(value)`) would cover every fade-in, fade-out and
intensity-follows-speed case, which is most of what an emitter is asked for
after it starts.
**Category:** API friction
## 25. `SceneTime.freezeFor` is the wrong shape for a cutscene, and the game is better without it
**Trying to do:** hold a node still while its intro plays — no knight walking
in, no mage beam, no levy timer, no node clock — while the storm, its visuals
and the camera keep animating.
**What happened:** `SceneTime.freezeFor` looks like exactly the right
primitive, and it does work, but three things came out of using it, and the
third one ended the experiment.
`freezeFor(duration)` has no open-ended form. A cutscene that ends when the
player skips it has to name a duration, so the hold is taken in blocks and
renewed while a line is still on screen — otherwise a player who walks away
returns to a node that started without them, with input still disabled:
```ts
/** `freezeFor` has no open-ended form, so the hold is renewed in blocks. */
const FREEZE_BLOCK = 120;
update(): void {
if (this.stage === "talk" && !this.freeze?.active) this.hold();
}
```
`excludeUpdates` covers the named entity only, not its children, so keeping
the storm running meant enumerating its whole subtree — for this game 7
children plus 34 wisp entities:
```ts
function subtree(root: Entity): Entity[] {
const out: Entity[] = [root];
for (const child of root.children.values()) out.push(...subtree(child));
return out;
}
```
A cutscene almost always wants "this actor and everything hanging off it", so
a subtree flag on the option, or a documented helper, would remove the most
likely way to get this wrong: excluding a parent, seeing its particles freeze,
and concluding the exclusion does not work.
The second half of the same trap cost real time. An entity spawned *after* the
freeze is not on the list, and a frozen entity gives no sign of being frozen —
it renders its first frame and holds it. The kingdom's speaker was staged
after the hold was taken, so he stood motionless through the exchange and did
not move when told to run, and on screen that reads as a missing animation
call rather than a time scale of zero. A reported list of what a request is
currently excluding, on the `SceneTime` inspector snapshot, would have shown
it at once.
**What the game does instead.** The freeze is gone. `buildTerrain` spawns the
ground, walls, props and buildings at scene load, and `spawnDefenders` spawns
the knights, archers and mages from the same event that starts the node's
clock and its HUD:
```ts
this.on(IntroFinishedEvent, () => this.startNode(built.buildings, built.walls));
```
Nothing has to be exempted, because nothing that acts exists yet, and the
storm holds its own energy and keeps its orbit shut behind one flag until the
same moment. That is a smaller mechanism and a better one: a frozen scene is a
running scene told to stand still, and everything that has to keep moving
during the cutscene has to be listed by hand. Deferring the spawn inverts it —
the list is of what starts, which the scene already had to know.
`freezeFor` remains right for what it was built for: hitstop, a freeze frame,
a slow-motion beat. It is the wrong tool for a sequence long enough that the
player can look around during it.
**Category:** API friction
## 26. The dialogue line event names the speaker, the channel identifies them
**Trying to do:** play a per-speaker voice blip on each revealed glyph — a low
click for the storm caller, a higher one for whoever the kingdom sent.
**What happened:** `DialogueLineEvent` carries `{ speaker?: string, text }`
where `speaker` is the resolved *display name* ("Alderic Vane"), so routing on
it means matching against the name string, which breaks the moment the name is
translated or edited. The speaker's stable id is reachable, but only through
an extra channel's `present(line)`, where `line.speaker` is a `SpeakerView`
with `id`, `name` and `color`:
```ts
new DialogueController({
...createStoryDialogue(),
channels: [{ present: (line) => (this.voice = line.speaker?.id ?? "vane") }],
onRevealTick: (index) => this.blip(index),
});
```
The seam works and is documented. The friction is that the obvious place to
look — the entity event — is the one that cannot answer "who is speaking",
and nothing at the call site says so. Adding `speakerId` beside `speaker` on the
event would make the common case reachable where an agent looks first.
**Category:** API friction
## 27. No blur in `@yagejs/effects`
**Trying to do:** render the caster inside the funnel as a soft, indistinct
shape — present but not the thing being looked at.
**What happened:** the effects package ships `motionBlur` and `zoomBlur`,
which are directional, and no plain gaussian or box blur. `wave` at a small
amplitude turned out to read well here (a figure seen through moving air is
the right idea for this game), so the substitution was a happy one, but it was
a substitution. A `blur({ strength })` preset is the one entry missing from an
otherwise complete set, and "soften this sprite" is a common enough ask that
its absence sends an agent looking for a workaround.
**Category:** missing primitive
## 28. A filter on a transparent overlay layer changes what is under it
**Trying to do:** bloom the additive effects — the funnel's swirl, the wind
wisps, beams and lightning — with `bloom()` on the `fx` layer.
**What happened:** a rectangle of altered ground travels with the storm. Its
edges are straight, its interior is washed grey, and the additive wisps inside
it come out *dimmer* than with no bloom at all. A player reported it three
times before it was pinned down.
The mechanism is the layer, not the preset. A filter on a container makes Pixi
render that container into a texture and composite the result back. The `fx`
layer is mostly transparent and its sprites rely on additive blending, so
flattening it into a texture and drawing that texture normally is not the same
picture: the ground beneath changes wherever the filter's own rectangle falls,
and the additive light is lost. The rectangle is the filter area, which is the
layer's content bounds plus padding — so it moves and resizes as the effects
move, which is why it reads as a border sliding over the world.
Padding does not fix it. Raising `blur` from 6 to 30 (the preset derives
`filter.padding` from `blur`) moved the edge outward and the clipping came with
it; dropping `quality` to 1 did not help either. The fix is not to filter a
transparent overlay: the bloom is gone, and the sprites there glow on their own
because they were already additive.
The engine could say so. `EffectsHost` is offered identically on a layer, the
scene and the screen, and nothing distinguishes "a layer of opaque content,
where a filter behaves" from "a transparent overlay, where it does not". A
dev-mode note when a filter is attached to a layer whose content is largely
transparent, or a line in the effects docs, would have saved three rounds.
**Category:** API friction
## 28b. Two wrong diagnoses, and what made them wrong
Before finding the bloom, this log carried two confident claims that were both
false, and they are worth recording because the cause was a method, not an
engine.
The first blamed `implosion`'s `darkness` term for painting a rectangle. The
second blamed `pixelArtPreset` for point-sampling `particle-smoke`, which
`CloudShadows` draws at 2 to 4 times its size. Both were "verified" by
screenshotting the game with the effect on, then off, and comparing.
That comparison is worthless here, and the reason is the game's own design:
`Ground` scatters grass tufts and `CloudShadows` places its clouds with the
scene RNG, which nothing seeds, and every screenshot lands on a different
animation frame. Two captures of "the same scene" share almost no pixels. Any
difference can be read as evidence for whatever was toggled.
Re-tested against a deterministic harness — `?seed` pins the scene RNG,
`inspector.time.freeze()` plus `step(n)` pins the frame, and two runs then
render pixel-identical — `darkness` produces a circular falloff of at most
8/255 and no rectangle, exactly as its shader says, and the smooth-texture
scale mode changes little more than the softness of a few streaks.
The lesson is cheap to apply and was skipped three times: **a visual
before/after needs a deterministic frame first.** Building the harness took
about as long as one of the wrong bisects.
**Category:** infra/test gap
## 29. No robed human at 192 px in the Tiny Swords style
**Trying to do:** give the game a protagonist who reads as a human wind mage —
a figure who stands on the field, talks to someone, and raises his arms to
summon the storm.
**What happened:** the library has one wizard,
`sprites/adventure/dungeon-wizard-purple.png` — a 16×16 Kenney dungeon tile,
twelve times too small and from another author, so the one-style-per-category
rule rules it out. Searching `staff`, `cloak`, `hood`, `robe`, `priest`,
`monk` and `caster` across `CATALOG.md` returns nothing else with a person in
it.
That leaves the three humans in the Tiny Swords set the rest of the game uses,
and two of them cannot carry a shot. The pawn and the archer are drawn from so
high that a 58×59 and a 62×75 opaque box is mostly hat: their overhead frames
(`chop-down`, `shoot-up`) read as a hat with something waving over it. Only
the warrior, at 78×91, has legs, a torso and a pose that holds an object
straight up.
So Vane is the warrior at 1.2× scale, colorized to violet at full strength
with a constant glow. He is human, he faces the person he is talking to, and
his raised arm reads. He is also unmistakably carrying a sword and a shield.
The gap is a robed or hooded civilian figure at 192 px in this set —
a villager in a cloak would cover mages, priests, hermits and any quest-giver
who is not a soldier, and every top-down fantasy game will want one.
**Category:** asset gap
## 30. The whole game was built with pixel-art filtering off
**Not an engine problem.** `RendererPlugin` takes `pixelArtPreset`, and
`docs/llms/packages/renderer.md` puts it in the first setup example with the
comment "crisp, non-blurred pixel art", then documents it again in its own
section twelve lines later. This game shipped seven passes without it.
**What that cost.** Every texture loaded at Pixi's default `"linear"` scale
mode and every sprite drew at whatever subpixel position its transform gave
it. On art authored at 1 px per pixel that softens every edge, and on the
Tiny Swords sheets — 192×192 frames packed with no padding — it also samples
across a frame border into the neighbouring pose. Marco's word for it was
"dirty edges", which is exactly what it looks like: outlines that should be
one hard black pixel come out as two or three muddy ones, and a building's
silhouette picks up a halo of the grass behind it.
**What fixed it.** One line:
```ts
new RendererPlugin({
width, height, container, fit: { mode: "letterbox" },
resolution: Math.min(window.devicePixelRatio || 1, 2),
pixelArtPreset: true,
});
```
Nearest sampling, whole-pixel sprite positions, and `image-rendering:
pixelated` on the canvas. Every surface got sharper at once — world, HUD,
almanac, dialogue box.
**Why it is in this log.** The observatory measures what an agent's build
comes out like, and this is the largest single visual defect in the game,
present from the first build, caused by not copying an optional line from an
example that was read. Nothing warns: there is no dev-mode notice when a
project loads image assets whose ids and sizes say pixel art while sampling
them bilinearly, and the game looks plausible enough at a glance that seven
passes of screenshots went by without anyone naming it. A one-time
development warning — "textures are sampling linearly; if this is a pixel-art
project, set `pixelArtPreset`" — would have caught it on the first run.
**Category:** docs ambiguity
## 31. Two small edges wiring in the feedback tool
Integrating `@yagejs-tools/feedback` took one plugin entry and one npm script,
and worked on the first run: the panel mounted, froze the frame, captured a
1280×720 screenshot and an inspector snapshot, and the CLI read the comment
back with the `open → ingested → addressed → resolved` transitions intact.
Two things needed a fix that a generated game will hit the same way.
`FeedbackOptions.context` is typed `() => Json`, and `Json` is a recursive
union whose object arm is not structurally satisfied by
`Record<string, unknown>` — the error names `Json[]` and asks for `push` and
`concat`, which reads as a mismatch about arrays rather than about index
signatures. Importing the exported `Json` type and annotating with it is the
fix, and worth saying out loud in the README next to the `context` example.
`enabled: import.meta.env.DEV` is the documented way to gate the panel, but
the scaffold's `tsconfig.json` declares no `types`, so `import.meta.env` is a
type error until `"types": ["vite/client"]` is added. Every game generated
from this scaffold has that tsconfig.
**Category:** docs ambiguity
## 32. Smooth textures under a pixel-art preset
`pixelArtPreset` (finding 30) sets every texture to nearest sampling. Eight
textures in this game are not pixel art: the blob shadow, the dust and the six
particles are alpha gradients, and `CloudShadows` draws one of them at 2 to 4
times its size.
`texture(path, { scaleMode })` is documented for the opposite case — one
pixel-art sheet in a smooth project — and works the same way round:
```ts
const SMOOTH = [TEX.blobShadow, TEX.dust, TEX.pSoft, TEX.pSmoke, TEX.pStreak, TEX.pGlow, TEX.pSpark, TEX.pSwirl];
export const TEXTURES = Object.values(TEX).map((path) =>
SMOOTH.includes(path) ? texture(path, { scaleMode: "linear" }) : texture(path),
);
```
Measured against a deterministic frame the change is small — a maximum of
36/255, and what it buys is slightly softer wisp streaks. It is the right way
round for a gradient and it is kept, but it fixed nothing that was reported;
an earlier version of this entry claimed it did, and that was the bad-method
mistake described in 28b.
Worth saying in the preset's own documentation all the same: almost every
pixel-art game mixes authored pixels with smooth effect sprites, and the
preset reads as a whole-project switch with no hint that half a project's
particle textures want the opposite.
**Category:** docs ambiguity
## 33. What the story pass got for free
- `@yagejs-addons/dialogue` carried the whole conversation UI from a
theme object and an action map: typewriter reveal, per-speaker nameplate
colours, an in-box portrait that reserves a column and reflows the body
text around it, `[i]` and `[pause=0.5/]` markup, hold-to-fast-forward,
press-to-skip, and pointer plus keyboard plus pad together. The only
game-side code is the theme, the cast table and the script.
- `defineScript` validated the script at load, so a speaker id typo is an
error at boot rather than a blank nameplate.
- `SortGroupComponent` answered the layering question exactly: the caster and
the funnel are one depth unit that sorts against the buildings by the
storm's position, and inside the group they keep insertion order, so
"the cone covers him" is decided by the order the children are spawned.
- Grid `FrameSource` (`{ sheet, frameWidth, columns, count, startY }`) reads
a single row out of a unit sheet, which is how three frames of an
overhead swing became a "raise the staff and hold it" pose without an
atlas or a hand-built animation.
- `createRenderTarget` baked six tinted portraits in six lines, which is what
it takes when a tint is a sprite property and the presenter wants a texture.