Web MD Open in Web MD

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), the create-game / port-lumos-game skills, 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.

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 your
gameplayRoot. 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

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 wraps
createTextBundle + 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 with
s = 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 as deps.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 with
e.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:

  1. Loader packages/app/src/games/<id>.ts — default-export a GameRunner;
    build asset URLs as static-literal new URL('../../../games/<id>/art/…', import.meta.url).href.
  2. packages/app/src/shared.ts — add '<id>' to the GameId union.
  3. packages/app/src/main.ts — add to GAME_THUNKS (label + dynamic import)
    and GAME_CAPS ({ modes, level }).
  4. packages/app/index.html — add the <option value="<id>">.
  5. packages/app/package.json — add "@candela/game-<id>": "*", then npm 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 the
verifier-candela-app skill / its drive.mjs, or a small custom driver:

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:

  1. Recoloring TEXT glyphs via setNodeColorMatrix renders them INVISIBLE.
    Don't tint text with a per-instance color matrix. Prewarm one A–Z glyph
    bundle set per color
    (createTextBundle(..., { fill })) and setNodeAsset
    to switch color. (Color matrix is fine for non-text shapes.)
  2. Creating a new text node per update races the async createTextBundle
    stale nodes stay mounted → overlapping/garbled text. Use one persistent
    makeText node and setText to mutate.
    Never parent.addChild a fresh
    text node on every change.
  3. 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 + detaches
    root.addChild(x) paired with a different parent's removeChild(x) silently
    no-ops and leaks.
  4. Hide nodes with node.active = false, not alpha = 0 (ENGINE_CONVENTIONS §3a).
  5. Interactable onDown takes PointerEventInfo, not (px, py) — read
    e.px/e.py.
  6. createTextField has no getValue — track the value in onChange; and
    lowercase defaults true.
  7. Text-bundle globalBBox is EM units (~0.7), not px — scale by bbox[3].
  8. Untimed streaming session needs onSessionStart to await forever
    (await new Promise(() => {})); it ends on quit/abort. Don't pass a duration.
  9. Pause slide-off only moves the ONE gameplayRoot node — put all sliding
    gameplay under it, backgrounds under root.
  10. 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

  1. Spec it first — fill GAME_SPEC_TEMPLATE.md
    (nail difficulty, scoring stat, content strategy).
  2. (Optional but recommended) DOM/CSS prototype to tune feel.
  3. Model + vitest tests (pure logic; write tests first).
  4. Board (Mantle primitives; heed the gotcha catalog).
  5. Factory (defineLumosGame; pick the session-end mode; theme the shell).
  6. run + game barrel + payload (+ payload-shape test).
  7. Tutorial (create-game wants one; scripted first trial, tutorial: true).
  8. Register (4 spots + npm install).
  9. Verify: typecheck + vite build + vitest + headless forced-frame run.
  10. Run evaluate-game for 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).