MD Reader Open in MD Reader

Candela Infra Wishlist

A living list of shared-infrastructure rough edges and affordance gaps found while
building games (Word Ladder and others). These are not game bugs — they live in
packages/engine, packages/lumosity, or the dev harness (packages/app), and fixing
them helps every game. Each item: the problem, where it lives, a proposed fix, who it
affects, and status.

Convention: keep game-specific issues in that game's README "Where to pick up". This
file is only for changes to shared code.

# Item Area Impact Status
1 Urgency ticker plays SFX unguarded @candela/lumosity any timed game w/o a timer cue Open (worked around)
2 Dev harness doesn't persist player_state across reloads packages/app testing any leveled/persistent game Done
3 No first-class extending-clock affordance on the runner @candela/engine any time-extension game Open
4 HUD pause button overlaps the first slot at near-square aspect @candela/lumosity cosmetic, dense (3–4 slot) HUDs Open (minor)
5 No shared leveling helper (clamp / persist / flush re-copied per game) @candela/engine? every leveled game Open
6 No shared hint/assist helper (5 hand-rolled cost models) @candela/lumosity? every game with hints Open
7 No central training-area + headline-stat registry (lives only in prose) metadata catalog / evaluate-game / new games Open
8 No first-class endless / until-quit session mode (dummy duration + hand-rolled end-promise) @candela/engine any no-clock endless game Open (worked around)
9 No shared result / summary card primitive (stars + headline stat + Continue) @candela/lumosity any game with a per-round result beat Open (hand-rolled)
10 No shared word-data package (each word game parses its own .list + indexes; corpus curated-incomplete) @candela/* (new) every word game (~7) + correctness Open
11 HUD custom metric slot won't refresh without a scoringStore write @candela/lumosity any non-score HUD metric Open (worked around)
12 No shared lives / hearts resource component (hand-drawn per game) @candela/lumosity any lives game Open
13 Repurposed streak-slot LEVEL badge overlaps the metric row in portrait @candela/lumosity cosmetic, "streak-as-LEVEL" games Open (minor)
14 roundRect (fill + stroke) leaks a square-cornered fill bbox behind the rounded stroke @kontek/mantle render / @candela/lumosity every board drawing a bordered rounded rect Open (worked around)
15 Headless verifier/driver can't send keyboard input (click-only) packages/app harness + verifier skill verifying any text-entry game's interaction behavior Open
16 No shared cover-fit raster background helper (each game re-derives the math) @candela/lumosity / @candela/engine any game with an authored background image Open (hand-rolled)

Items 5–7 were surfaced while building docs/mechanics/ — see the linked
dimension file in each entry below for the evidence. Items 8–9 were surfaced building
Swap Grid (the first no-clock, per-grid-result game). Items 10–13 were surfaced building
Intruder Grid (net-new untimed word game) — see docs/INTRUDER_GRID_PLAN.md.
Items 14–16 were surfaced during Word Ladder's v1.4 art + motion pass (see
word-ladder-spec.md §20); that pass also confirmed #8 (Endurance
mode shipped on the dummy-duration + end-promise workaround) and added Word Ladder to #9.


1. Urgency ticker plays SFX unguarded (Open — worked around)

Where: packages/lumosity/src/session/urgencyTicker.ts (~L38).

Problem: attachUrgencyTicker calls engine.audio.play(opts.scope, opts.sfxId, …) in the
final seconds with no guard that the sound id is truthy / actually loaded. A timed game
that doesn't ship a timer cue throws HowlerBackend: sound 'xx.timer' not loaded (surfaces as
an uncaught pageerror). The intro overlay does the right thing —
mountIntroOverlay.ts guards its cue
with if (signal.cancelled || !id) return — but the urgency ticker does not.

Proposed fix: guard the play in urgencyTicker (skip when sfxId is falsy or not loaded),
and/or make engine.audio.play tolerant of an unknown sound (warn + no-op instead of
throw). The latter is the more robust platform fix.

Impact: any timed game whose shell theme leaves a default ${prefix}.timer id but doesn't
preload that asset. Word Ladder hit this; the workaround was to preload the shared audio set
and then disable the ticker (enabled.urgencyTicker: false).


2. Dev harness doesn't persist player_state across reloads (Done ✓)

Where: packages/app/src/main.ts (engine boot + session-end).

Problem: the engine keeps saved state (engine.saveData(...) → levels, personal bests) in an
in-memory Map and only emits it as player_state; the host is responsible for persisting
that blob
(by design — see Engine.ts seedSavedState/
serializeSavedState). The real product's host does this; the dev harness booted Engine.boot
directly and never round-tripped it to localStorage, so within a tab state survives (same
engine instance) but a page reload resets everything to defaults. That makes it impossible to
watch progression (level trend, best-chain over days) while testing in vite.

Fix (implemented): on boot, seed the engine's saved-state cache from localStorage
(engine.seedSavedState(JSON.parse(...))) before any game creates its SaveStores; persist
engine.serializeSavedState() back to localStorage after each session ends (debounced, so the
end-of-session level write lands first) and on pagehide / visibilitychange:hidden. One
localStorage key holds the whole cache; each game reads its own userId.namespace.* slice.

Impact: every leveled / persistent game (Word Ladder, Color Match, Eagle Eye, …) now
remembers across reloads in the harness — matching real-host behavior for testing.


3. No first-class extending-clock affordance on the runner (Open)

Where: packages/engine/src/runtime/SessionRunner.ts.

Problem: the runner's duration is a fixed countdown (getRemaining() = duration − elapsed).
Games that want an extending clock — where correct answers add time — can't use it; they must
give the runner a large dummy duration, own the clock in game state, feed the HUD via a
custom hudFirstSlot.value, and end the session themselves. This boilerplate is duplicated across
Space Trace, Chalkboard Challenge, and now Word Ladder.

Proposed fix: a runner API for a mutable clock — e.g. runner.addTime(seconds) /
runner.setRemaining(seconds) that adjusts state.timeRemaining, with the default HUD TIME slot
binding to it and the timer-expiry promise honoring it. Games would then just call addTime on a
correct move and drop the dummy-duration + custom-slot boilerplate.

Impact: any time-extension game. Purely an ergonomics/consistency win — the pattern already
works.


4. HUD pause button overlaps the first slot at near-square aspect (Open — minor)

Where: the Lumos HUD layout (packages/lumosity/src/hud/*) + pause affordance placement.

Problem: with a dense HUD (3–4 slots) on a near-square viewport, the shell's top-left
pause button visually kisses the first slot (e.g. the "T" of "TIME" sits under the pause icon).
Fine in portrait; noticeable at ~1:1.

Proposed fix: reserve the pause-button width in the HUD strip layout (inset the first slot by
the pause footprint), or right-shift the slot row when the pause button is present.

Impact: cosmetic; any game running a 3–4 slot HUD at a squarish aspect (Word Ladder's
TIME · LEVEL · SCORE · ×N).


5. No shared leveling helper (Open)

Where: every leveled game re-implements the same shape — see docs/mechanics/04-difficulty-leveling.md.

Problem: the update rule legitimately differs per game (WPM vs links/min vs accuracy vs
staircase), but the surrounding boilerplate is copy-pasted everywhere: clampLevel, read the
persisted level at build, set + flush at session end, feed the HUD LEVEL slot. There's no
@candela/leveling (or engine) helper for the common parts.

Proposed fix: a small shared helper — clamp(min,max), a saveData-backed
persistentLevel(store) accessor, and a staircase({up,down,...}) builder (Pinball Recall /
Memory Span / Skyrise re-derive the same 1-up/2-down math). Each game keeps only its bespoke
"compute next level" function.

Impact: every leveled game (~20+). Ergonomics + fewer off-by-one clamp/persist bugs.


6. No shared hint/assist helper (Open)

Where: every game with hints hand-rolls the budget + cost — see docs/mechanics/05-hints-assists.md.

Problem: five distinct cost models exist (token + streak knock-down, token + score-cap +
streak-reset, no-credit, score-penalty + slower advance, free scaffold), each re-implemented
from scratch: the budget counter in factory.ts, the hintUsed flag threaded through
runner.recordArrival, and the score/streak cost in model/. No shared plumbing.

Proposed fix: a small hintBudget({ perRound, onSpend }) helper that owns the counter +
the hintUsed telemetry flag, leaving each game to supply the surfacing (which word/cell) and
the cost policy. (Also: Word Bubbles' fromHint-scores-0 branch is real in the model but
unwired in its factory — a game-level bug to fix separately, noted in the mechanics file.)

Impact: every game with hints. Consistency + correct telemetry.


7. No central training-area + headline-stat registry (Open)

Where: the cognitive Area (Flexibility / Memory / Attention / Speed / Problem Solving /
Language) and the LPI headline stat live only in per-game spec/README prose + payload.ts
— see docs/mechanics/12-training-domain.md. Only ~3 games
carry an explicit Area: line; the rest are inferred.

Problem: there's no machine-readable source of truth mapping each game → area + headline
stat, so the catalog, the evaluate-game gate ("score must reflect skill"), and "pick an
underserved lane" for a new game all rely on prose. Several games expose only raw score,
leaving the LPI headline ambiguous.

Proposed fix: a small registry (e.g. a field in each game's loader/GAME_CAPS, or a
docs/mechanics/12 companion JSON) declaring { area, headlineStat }, validated against the
payload. Low effort, unblocks tooling.

Impact: catalog accuracy, evaluate-game automation, new-game placement.


8. No first-class endless / until-quit session mode (Open — worked around)

Where: packages/engine/src/runtime/SessionRunner.ts.

Problem: the streaming runner's session end is driven by its fixed duration countdown. A
game that should run endlessly until the player quits (no clock at all — the round ends on a
per-item condition, the session ends on abort) has no mode for it. Word Ladder
(endurance), Swap Grid, and now Intruder Grid fake it the same way: pass a large dummy duration
(RUNNER_DURATION_CAP), create an endPromise, register sessionScope.add(() => resolveEnd())
so scope-close (quit/restart) resolves it, and await endPromise inside onSessionStart. That's
~6 lines of non-obvious plumbing (and the dummy duration leaks into the HUD unless the first slot
is overridden). The BUILDING_GAMES_GUIDE §4 documents the await new Promise(() => {}) variant,
which is simpler but then the session can't end cleanly on abort without the scope-close hook.

Proposed fix: a runner option — e.g. mode: 'streaming', endless: true (or duration: 'until-quit') — that omits the countdown, drops the TIME slot by default, and resolves the run
when the scope closes, so games skip the dummy-duration + end-promise boilerplate.

Impact: any no-clock endless game. Ergonomics + removes the leaky dummy duration. Distinct
from #3 (that's an extending clock; this is no clock).


9. No shared result / summary card primitive (Open — hand-rolled)

Where: would live in @candela/lumosity (alongside the intro/feedback/pause overlays).

Problem: the shell supplies the 3-2-1 intro, check/X feedback, and pause overlay, but not a
per-round result card — the "you finished this board" reward beat (star rating, a headline
number, a "best" line, and a primary Continue/Next button). Swap Grid hand-rolled the whole thing
in its board (board.ts showResult),
Intruder Grid re-drew the same game-over overlay independently (scrim, rounded card panel,
three baked SVG star polygons, three text runs, primary button + interactable + hit-test), and
Word Ladder hand-rolled yet another variant: a scrim + rounded panel + 6 stat rows (label →
value, with a NEW! personal-best flag), a Play again button, a ladder emblem badge crowning
the card, and a bespoke reveal animation (panel scale/fade-in + per-row stagger + emblem pop,
bigger on a new best — see board.ts
showResults). Three games, three from-scratch result overlays; the star widget, the label→value
row list, the personal-best flag, and the entrance animation are all copy-worthy.

Proposed fix: a shell.resultCard.show({ stars?, headline, rows?, best?, emblem?, primaryLabel })
overlay (returning a promise that resolves on the primary tap), with a built-in reveal (panel
scale/fade + row stagger) and optional identity emblem slot, plus reusable star-rating + label→value-row
render helpers. Themeable like the other shell overlays; games pass data, not geometry.

Impact: any game with a discrete per-round result screen (Swap Grid, Word Ladder today; a natural
fit for Crosswise grid-clear, Memory Matrix round summaries, etc.). Consistency + a lot less board code.


10. No shared word-data package (Open)

Where: would live as a new @candela/wordlists (or @candela/words) package; today each
word game ships + parses its own list under art/data/.

Problem: Crosswise, Word Ladder, Word Bubbles, Word Snatchers, Clue Rush, Swap Grid, and
Intruder Grid each ship a word list and re-implement the same plumbing: fetch the .list, parse
to a Set, build per-game prefix/length indexes. Worse, there is no single trusted corpus
Word Ladder's dictionary.list is a curated subset that is missing very common words
(OTHER, GHOST, OWNER, AWARD are absent). Intruder Grid's fairness checker needs a
comprehensive validity dictionary, so it had to build a union of the Scrabble list ∪
/usr/share/dict/web2 ∪ a hand-curated common pool to avoid mis-judging a real word — a
correctness hazard, not just duplication.

Proposed fix: a shared package exposing (a) a frequency-tiered word set — a broad
validity tier and a common/recognizable tier — with lengths indexed, (b) prebuilt prefix
indexes, and (c) a generic single-edit uniqueness helper (the "is this string uniquely one
edit from a real word, and where?" check Intruder Grid's fairness.ts implements). Games import
tiers + helpers instead of shipping and re-indexing their own list.

Impact: every word game (~7). Removes duplicate parsing, and — more importantly — gives one
audited common/valid corpus so fairness/validity checks aren't each game's private guess.


11. HUD custom metric slot won't refresh without a scoringStore write (Open — worked around)

Where: packages/lumosity/src/hud/mountDefaultHud.ts
(makeStateProps is re-evaluated on the scoringStore subscription).

Problem: theme.hudFirstSlot.value: (runner) => string lets a game show a non-score
metric (Intruder Grid's LEFT = intruders remaining; Eagle Eye's TRIAL). But the slot's value
callback is only re-run when the scoringStore publishes — so to update a metric that isn't
score, the game must call scoringStore.set(...) purely to force a HUD refresh (Intruder Grid
pushes the store on every catch just so LEFT updates). There's no hud.refresh() and no way to
bind a slot to its own reactive source.

Proposed fix: either expose shell.hud.refresh() (re-evaluate the slot callbacks on demand),
or let a slot bind to an arbitrary Store so hudFirstSlot: { store, format } updates
independently of scoring.

Impact: any game whose HUD shows a metric other than the score (counters, lives-left,
remaining-targets). Ergonomics + avoids spurious scoringStore writes.


12. No shared lives / hearts resource component (Open)

Where: would live in @candela/lumosity (a game-drawn HUD resource, like the streak pips).

Problem: a lives resource (N filled/empty hearts that decrement on a mistake) is a common
pattern, but there's no shared widget — Raindrops draws its water level, and Intruder Grid
hand-draws three heart SVGs + their layout + fill/empty asset swap in its board. Each new
lives-based game reinvents it.

Proposed fix: a small livesMeter({ max, icon? }) HUD component (mirror the streak-pip meter)
that renders N filled/empty icons and exposes set(n); themeable icon. Games call set(lives).

Impact: any lives / attempts-limited game (Raindrops, Intruder Grid, and a natural fit for
future survival games).


13. Repurposed streak-slot LEVEL badge overlaps the metric row in portrait (Open — minor)

Where: the Lumos HUD streak/level slot (packages/lumosity/src/hud/*) in the portrait
layout.

Problem: the common "repurpose the streak slot as a LEVEL N badge" pattern
(streak: { showLevelNumber: true, formatLevel }, used by Crosswise and Intruder Grid) lays out
fine in landscape but in portrait the LEVEL badge overlaps the LEFT · SCORE metric row and a
stray pip-sized glyph renders under the grid (observed in Intruder Grid at 540×960). Distinct from
#4 (that's the pause button vs the first slot in near-square).

Proposed fix: give the level/streak slot its own reserved row/position in the portrait HUD
stack (or a first-class LEVEL slot separate from the streak-pip slot), so the badge doesn't
collide with the metric row.

Impact: cosmetic; any "streak-slot-as-LEVEL" game viewed in portrait.


14. roundRect (fill + stroke) leaks a square-cornered fill bbox (Open — worked around)

Where: Mantle's SVG rasterization (@kontek/mantle)
of a single <rect> carrying both a fill and a stroke with rx; surfaces in every board's
roundRect(fill, stroke, rx, sw) helper.

Problem: a rounded rect drawn as one <rect fill=… stroke=… rx=…> renders the fill as a
full square-cornered bbox
while only the stroke is rounded — so a square of fill pokes out
behind the rounded border. It's latent everywhere (input field, buttons, chain rungs, result
panel) but invisible when fill/stroke contrast is low (white fill + cream hairline); Word Ladder's
teal-bordered white hero card exposed it. solidRect (fill-only) rounds correctly, which is
the tell — it's the fill+stroke combination that breaks. The same roundRect helper is copied
into Crosswise and Clue Rush boards, so they carry the latent bug too.

Proposed fix: fix it in Mantle (round the fill path when a stroke is present), or ship a
shared safe helper (@candela/lumosity) that always composes a fill-only rounded plate + a
separate fill="none" stroke-only outline — the two-rect form Word Ladder now uses. A shared helper
also kills the per-game copy-paste of roundRect/solidRect.

Impact: every board that draws a bordered rounded rect (all of them). Correctness/visual, not
just ergonomics.


15. Headless verifier/driver can't send keyboard input (Open)

Where: the verifier driver .claude/skills/verifier-candela-app/scripts/drive.mjs
(CDP-driven; supports --click-at / --clicks only) + the dev harness.

Problem: the driver can force frames and click, but has no way to type. So for
text-entry games — Word Ladder, Crosswise, Clue Rush, Word Bubbles, Word Snatchers — the whole
input-driven half of the game can't be exercised headlessly: a valid move, the changed-letter
pop, the card pulse, the ×N tier-up, a full chain, and the results screen all require typed words.
Word Ladder's v1.4 motion pass could only verify the seed/idle screen + the clock-driven results;
the three interaction animations were shipped on typecheck + a no-regression render, not observed.

Proposed fix: add a keyboard path to the driver — a --type "WORD1;WORD2;…" (or
--keys) flag that dispatches CDP Input.dispatchKeyEvent / insertText into the focused
soft-keyboard field between frame batches, so a scripted sequence of valid moves can be driven and
screenshotted. Pairs with the existing --click-at timeline.

Impact: verifiability of every text-entry game (~5). Today their core loop is a manual-only check.


16. No shared cover-fit raster-background helper (Open — hand-rolled)

Where: would live in @candela/lumosity (or as an @candela/engine render convenience).

Problem: every game with an authored background image re-derives the same cover-fit math:
loadRaster(url), read imgW/imgH off the asset bundle.globalBBox (or a separate measureImage),
scale = max(vw/imgW, vh/imgH), anchor center, re-apply on viewport change, and guard with a
procedural fallback. Ebb and Flow, Chalkboard Challenge, Contextual, Tidal Treasures, and now
Word Ladder each hand-roll it slightly differently.

Proposed fix: a mountCoverBackground(engine, scope, parent, url, { fallback }) helper that
loads the raster, cover-fits + re-fits on viewport.subscribe, and falls back to a supplied
procedural scene if the load fails — returning the node. Games pass a URL, not geometry.

Impact: any game with a full-bleed background image. Ergonomics + one correct cover-fit/rotate
implementation instead of five.