Building a game in Candela — a from-scratch guide
Who this is for. Anyone (human or AI agent) adding a new game to
Candela. It's the practical, end-to-end companion to the reference docs:
README.md (project overview), ENGINE_CONVENTIONS.md
(engine-module rules), thecreate-game/port-lumos-gameskills, and the
spec template GAME_SPEC_TEMPLATE.md.It was written after building two net-new games from scratch
(crosswise, word-ladder),
so it front-loads the things that otherwise cost churn. Read §"Gotcha
catalog" before you write any rendering code.
1. Mental model (30 seconds)
Candela is an engine + a monorepo of ~50 brain-training games.
- Mantle is the renderer: it rasterizes vector geometry + text on the GPU
(crisp at any scale, tiny assets, one embeddable canvas; also has a native-SVG
fallback). You draw with SVG strings and text bundles, not pixels. - The Lumos shell (
@candela/lumosity) supplies the standard game chrome —
HUD (TIME · SCORE · LEVEL), 3-2-1 intro, pause overlay, check/X feedback — so
you don't rebuild it per game. defineLumosGame+SessionRunnerown the session lifecycle (intro →
play → end → payload). Your game plugs its own loop into that.
A game is a package under packages/games/<id>/ that exports a factory; the
app harness (packages/app) loads and runs it.
2. Which path — net-new vs. port
| You're building… | Skill | Reference games |
|---|---|---|
| A new original game | create-game |
crosswise, word-ladder (net-new, primitive-drawn) |
| A Flash/Unity port | port-lumos-game / port-lumos |
ebb-and-flow, color-match, memory-matrix |
Both share the same package layout, defineLumosGame, shell, and verification —
ports add Flash-art extraction. This guide covers the net-new path; skip the
extractor sections of the port skill.
Recommended workflow (what actually worked): prototype the mechanic as a
self-contained HTML page first (fast feel iteration — see
docs/crosswise.html), then port that logic into the Candela
package.
3. Anatomy of a game package
Copy the shape of crosswise (timed) or
word-ladder (untimed). Minimum:
packages/games/<id>/
package.json # name "@candela/game-<id>", deps @candela/engine + @candela/lumosity, vitest
tsconfig.json # extends ../../../tsconfig.base.json
src/
model/ # PURE logic — no rendering. Unit-tested standalone.
types.ts # domain types
<content>.ts # embedded bank / dictionary / level table
scoring.ts # scoring, difficulty (seeded via engine Rng)
*.test.ts # vitest — write these FIRST
index.ts # barrel
board/ # the VIEW — Mantle primitives + input hit-tests. Render-only.
board.ts
text.ts # copy from crosswise/word-ladder (makeText helper)
factory.ts # defineLumosGame — owns state, runner, shell, scoring, telemetry
run.ts # entry: install factory, run session, restart/replay loop
game.ts # public barrel (run*, factory, payload, model)
payload.ts # flat analytics payload (headline stat)
art/ # only if you ship assets (word-ladder ships its dictionary here)
Golden rule: model is pure and tested; board is render-only; factory owns
state. Keeping game logic out of the view is what makes it testable and what
lets the factory drive everything.
4. The session lifecycle (defineLumosGame + SessionRunner)
Never hand-roll the old factory skeleton. Use defineLumosGame<Deps, Assets>:
export const makeMyGameFactory = defineLumosGame<MyDeps, MyAssets>({
gameId: 'my-game',
idPrefix: 'mg', // SFX/asset id namespace
pickShellWiring: (deps) => ({ font: deps.font, fontMedium: deps.fontMedium }),
loadGameAssets: async (ctx, deps) => { // preload art/audio/data
// ctx.audio.preloadPrefixed('mg', deps.audio); ctx.render.loadSvg(...)
return { /* whatever the build step needs as gameAssets */ };
},
build: async ({ engine, config, deps, sessionScope, gameAssets, mountShell, finalize, root, setEngineMode }) => {
// 1. gameplay layer (ALL sliding gameplay under ONE node the shell adopts)
const gameplayLayer = new Node({ id: 'mg.gameplay', scope: sessionScope });
root.addChild(gameplayLayer);
// 2. board (render-only), scoring store, state...
const scoringStore = createStore<ScoringSnapshot>({ score: 0, multiplier: 0, progress: 0, streak: 0 });
// 3. forward-ref: runner needs shell.intro; shell needs runner.
let shell: LumosSessionShell;
const runner = new SessionRunner({
gameId: config.gameId, rng, time: engine.time, pause: engine.pause, scope: sessionScope,
mode: 'streaming', // or 'trial'
scoringStore, interactables: engine.interactables,
presentIntro: async () => { await shell.intro.run(); },
onSessionStart: async () => { await mainLoop(); },
});
shell = await mountShell({ gameplayRoot: gameplayLayer, runner, theme: { /* … */ } });
// 4. run() → payload → finalize
const run = async () => finalize(await runner.run(), buildPayload(totals));
return { runner, run };
},
});
Key ctx fields: engine, config (gameId/mode/seed/params),sessionScope, gameAssets, mountShell, finalize, root, setEngineMode,
plus load (game-lifetime asset loaders that survive restart).
The three session-end modes (pick one — this was undocumented and cost time)
// (a) TIMED sprint (Crosswise, Clue Rush): the runner owns a countdown that
// feeds HUD TIME and ends the session. Race each trial against it.
new SessionRunner({ mode: 'streaming', duration: 60, /* … */ });
const timeoutP = runner.awaitTimeout(sessionScope).then(() => 'timeout');
while (!timedOut) { await Promise.race([playOneTrial(), timeoutP]); }
// (b) FIXED-COUNT: loop N trials inside onSessionStart, then return → session ends.
onSessionStart: async () => { for (let i = 0; i < N; i++) await playOneTrial(); }
// (c) UNTIMED / ENDLESS (Word Ladder): NO duration. Play until the player
// quits — onSessionStart awaits a promise that only the abort resolves.
new SessionRunner({ mode: 'streaming' /* no duration */ });
onSessionStart: async () => { setup(); await new Promise<void>(() => {}); }
runner.recordArrival({ config, response, result }) once per trial → telemetry.finalize(session, payload) builds the SessionOutcome; payload is a flat,
game-specific object (see §7).
5. The Lumos shell + theming
mountShell(gameOpts) mounts HUD / intro / pause / feedback into yourgameplayRoot. You pass only game-specific knobs:
shell = await mountShell({
gameplayRoot,
runner,
theme: {
hudTextColor: [0.1, 0.09, 0.08, 1],
// Repurpose the streak slot as a "LEVEL N" badge (Crosswise):
streak: { pipCount: 0, showLevelNumber: true, formatLevel: (n) => `LEVEL ${n}`,
levelReserveText: 'LEVEL 99', maxMultiplier: 99 },
},
enabled: { hud: false }, // hide the whole HUD (Word Ladder draws its own chrome)
});
runner.setPresentIntro?.(() => shell.intro.run()); // or a no-op for a calm game
- Feed the HUD by writing
scoringStore.set({ score, multiplier, progress, streak }).multiplierdrives the LEVEL badge if you repurpose the streak slot. - Timed game → default HUD (TIME · SCORE · LEVEL).
- No timer →
hudShowFirstSlot: false(don't fake a clock). - No level / no streak →
streak: { pipCount: 0, showLevelNumber: false }. - Bespoke chrome (own title/stats, like Word Ladder) →
enabled: { hud: false }
and draw your own; the shell still gives you pause + lifecycle. - Calm game, no countdown →
presentIntro: async () => {}. - Feedback:
await shell.feedback.show(correct)plays the canonical check/X. - Pause slide-off: mount ALL sliding gameplay under the ONE
gameplayRoot
node; the shell tweens it on pause. Don't scatter gameplay underroot.
Backgrounds that should stay put on pause go underrootas siblings.
6. Rendering with Mantle
Coordinate contract
engine.camera.viewport → { width, height } in world units where the short
edge ≈ referenceUnit (100). Lay out in these units. Convert input px →
units with engine.camera.pixelToUnitXY(px, py). Re-layout on resize/rotate:sessionScope.add(engine.viewport.subscribe(() => board.relayout())).
Drawing primitives
const r = engine.render; // MantleAdapter
const asset = await r.loadSvg('id', '<svg …/>', scope, { preserveViewport: true });
const node = new Node({ id, scope }); parent.addChild(node);
r.mountNode(node, asset, { zOrder: 10, anchor: 'center' }, scope);
node.transform.position.x = cx; node.transform.scale.x = w / 100; // 100 = the svg viewBox
r.setNodeAsset(node, otherAsset); // swap fill/state cheaply
r.setNodeAlpha(node, a); node.active = false; // hide with `active`, NOT alpha 0
Rounded-rect cell/panel/button = an inline <svg><rect rx=.../></svg>. Cache one
asset per (fill,rounding); swap assets for states.
Text — use the makeText helper, don't hand-roll
Copy board/text.ts from crosswise/word-ladder. It wrapscreateTextBundle + placeTextInRect into one persistent, mutable node:
const t = await makeText({ engine, scope, parent, id, text: ' ', fill: INK,
rect: { x, y, w, h }, align: 'center', capHeightFraction: 0.6, font });
await t.setText('HELLO', ACCENT); // mutate text + recolor; refits into the rect
t.place(newRect); // re-fit on relayout
For many small glyphs that change often (grid letters), prewarm A–Z bundles once
and setNodeAsset per cell (faster than async setText).
Sizing note: a text bundle's globalBBox is in EM-ish units (~0.7 tall),
not px, even with fontSizePx. Scale a baked glyph to a target height withs = targetH / (bbox[3] || EM).
Fonts are bundled files — the canvas has no system fonts. The app passes
fonts.bold/medium/...; pass the one you need asdeps.font.
7. Input, scoring, payload, persistence
Keyboard is global: engine.input listens on window (no focus needed).
Pointer/tap: engine.interactables.add({ id, hitTest:(px,py)=>bool, onDown:(e)=>…, cursor }, scope) — onDown gets a PointerEventInfo withe.px/e.py (CSS px; convert with pixelToUnitXY).
Typing (word games): createTextField({ input, scope, pause, maxLength, lowercase, softKeyboard: { element: engine.canvas }, onChange, onSubmit, onBackspace }). It handles physical keys and the mobile soft keyboard.
Gotchas: no getValue() (track the value yourself in onChange);lowercase defaults true (set lowercase: false if you want caps); tap the
field then call textField.focusSoft() inside a pointer handler to summon the
mobile keyboard.
Scoring/HUD: createStore<ScoringSnapshot>({score, multiplier, progress, streak}), mutate with .set/.update; the shell HUD mirrors it.
Payload (finalize(session, payload)): a flat game-specific object. Pick
one headline metric = the LPI stat (e.g. clues_solved, links). Lock the
shape with a payload-shape unit test. Report level_previous/level_next if you
have levels; use sentinels if not.
Persistence (personal bests, next-session level):engine.saveData(userId, gameId, scope).store<T>({ key, initialValue }) →.get()/.set()/.flush(). Word Ladder persists best_chain/best_score this way.
8. Registration (4 spots + install)
To make the game appear in the app harness:
- Loader
packages/app/src/games/<id>.ts— default-export aGameRunner;
build asset URLs as static-literalnew URL('../../../games/<id>/art/…', import.meta.url).href. packages/app/src/shared.ts— add'<id>'to theGameIdunion.packages/app/src/main.ts— add toGAME_THUNKS(label + dynamic import)
andGAME_CAPS({ modes, level }).packages/app/index.html— add the<option value="<id>">.packages/app/package.json— add"@candela/game-<id>": "*", thennpm install.
9. Verifying it actually runs
Necessary but not sufficient: npx tsc --noEmit -p packages/games/<id> +cd packages/app && npx vite build + npx vitest run.
The real check is pixels, and headless WebGL has one gotcha: Chromium
throttles requestAnimationFrame, so the intro/animator stalls unless you FORCE
frames by driving CDP Page.captureScreenshot({ fromSurface:true }) in a loop
(Playwright's page.screenshot() does NOT force the surface). Use theverifier-candela-app skill / its drive.mjs, or a small custom driver:
- launch with
channel: 'chrome'(system Chrome, avoids the missing headless-shell binary), page.addInitScriptany debug hook, pumpcaptureScreenshotin the background,- select the game in
#sel-game,#btn-start, then read frames.
The bundled driver is click-only — for typing games write a keyboard
driver (page.keyboard.type / .press) and optionally a flag-gated debug hook
on globalThis to reach late-game state; grep the hook out before finishing.
10. Gotcha catalog — read this first
The footguns that cost real churn building from scratch:
- Recoloring TEXT glyphs via
setNodeColorMatrixrenders them INVISIBLE.
Don't tint text with a per-instance color matrix. Prewarm one A–Z glyph
bundle set per color (createTextBundle(..., { fill })) andsetNodeAsset
to switch color. (Color matrix is fine for non-text shapes.) - Creating a new text node per update races the async
createTextBundle→
stale nodes stay mounted → overlapping/garbled text. Use one persistentmakeTextnode andsetTextto mutate. Neverparent.addChilda fresh
text node on every change. - Dynamic subtree teardown: to rebuild a subtree per trial/grid, create a
child scope + a root node under it, and register detach on close:const s = scope.child(`g.${n}`); const gr = new Node({ id, scope: s }); parent.addChild(gr); s.add(() => parent.removeChild(gr)); // …mount cells under gr… ; on next trial: s.close(); // disposes + detachesroot.addChild(x)paired with a different parent'sremoveChild(x)silently
no-ops and leaks. - Hide nodes with
node.active = false, notalpha = 0(ENGINE_CONVENTIONS §3a). - Interactable
onDowntakesPointerEventInfo, not(px, py)— reade.px/e.py. createTextFieldhas nogetValue— track the value inonChange; andlowercasedefaultstrue.- Text-bundle
globalBBoxis EM units (~0.7), not px — scale bybbox[3]. - Untimed streaming session needs
onSessionStartto await forever
(await new Promise(() => {})); it ends on quit/abort. Don't pass aduration. - Pause slide-off only moves the ONE
gameplayRootnode — put all sliding
gameplay under it, backgrounds underroot. - Headless rAF stall — force CDP frames or you'll see a false "it's broken."
11. Reference games by closeness
| Your game is… | Copy from |
|---|---|
| Timed word/typing sprint | crosswise, clue-rush, word-bubbles |
| Untimed / endless / bespoke-HUD | word-ladder |
| Trial + click + HUD + feedback | color-match, eagle-eye |
| Discrete multi-phase trials | memory-matrix, ebb-and-flow |
| Streaming / own per-frame loop | highway-hazards, train-of-thought |
| Code-built responsive layout | raindrops |
| Anagram / letter-select | word-snatchers |
12. From-scratch checklist
- Spec it first — fill GAME_SPEC_TEMPLATE.md
(nail difficulty, scoringstat, content strategy). - (Optional but recommended) DOM/CSS prototype to tune feel.
- Model + vitest tests (pure logic; write tests first).
- Board (Mantle primitives; heed the gotcha catalog).
- Factory (
defineLumosGame; pick the session-end mode; theme the shell). - run + game barrel + payload (+ payload-shape test).
- Tutorial (create-game wants one; scripted first trial,
tutorial: true). - Register (4 spots +
npm install). - Verify: typecheck + vite build + vitest + headless forced-frame run.
- Run
evaluate-gamefor the design-quality acceptance pass.
Maintenance: when the next build hits friction not covered here, add it to §10.
This doc is meant to shrink the gap between "port an existing game" (well-trodden)
and "build net-new" (this guide).