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 inpackages/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 (seeword-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; persistengine.serializeSavedState() back to localStorage after each session ends (debounced, so the
end-of-session level write lands first) and on pagehide / visibilitychange:hidden. OnelocalStorage 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'sTIME · 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-backedpersistentLevel(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 throughrunner.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 adocs/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.tsshowResults). 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'tscore, 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'sroundRect(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.