Build a game · celworld docs

Build a game

A stage, an art table, an environment scene and guest saves, with Fold-Up Factory as the worked example.

A game on celworld is a page on the site with a stage that draws library models, built-ins and a .scene environment, a React HUD over it, and a rules package the server can re-run. Fold-Up Factory is the worked example: a packing puzzle in a toy factory, built from agent-made models, reused library models and built-ins.

Where a game's code lives

LayerFold-UpRule
Rules packagegames/foldup/No three.js, no React: shapes, puzzle, economy, levels, state reducers and the art table. The stage, the HUD and the server all import it
Artpackages/engine/src/dsl/scripts/foldup/*.celSaved to the library and pinned by version in the art table
Stageapps/web/src/viewport/games/foldup/The three.js class the page talks to, the art loader, the room from a .scene, posed clones
Page and HUDapps/web/src/app/games/foldup.tsxThe route module, HUD cards, copy, sound, the QA hook
Serverapps/web/server/routers/foldup.tsGuest saves over tRPC, re-validated by the shared reducers

One renderer per page

The site creates one WebGL renderer and leases it. A stage never makes its own:

import { leaseRenderer, type RendererLease } from '../../renderer'

this.lease = leaseRenderer(host)          // the canvas moves into host; a second live lease throws in development
this.renderer = this.lease.renderer       // never new WebGLRenderer, never dispose it
this.effect = new OutlineEffect(this.renderer, { defaultThickness: OUTLINE_THICKNESS })

dispose(): void { /* dispose your scene, protos and materials */ this.renderer.info.reset(); this.lease.release() }

The React side is <Viewport load={loadStage} label="…">. It runs your loader inside an effect (three.js never loads during server rendering), releases the lease before the next view takes it, and disposes the stage on unmount. Load the stage module with a dynamic import() inside the loader:

async function loadStage(host: HTMLDivElement): Promise<FoldupStage> {
  const { FoldupStage: Stage } = await import('~/viewport/games/foldup/stage')
  return new Stage(host, {
    scripts: (refs) => getBrowserClients().trpcClient.models.scripts.query({ refs }),
    reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
  })
}

Render through the outline effect and read the counters every frame:

this.renderer.info.reset()
this.renderer.clear()                       // the outline effect never clears on its own first pass
this.effect.render(this.scene, this.cam)
this.frameCalls = this.renderer.info.render.calls

The environment is a scene

One material set per stage: scene.makeSceneMaterials() gives the toon ramp, the cel materials for each ink class, ground, sky and shadow. Fold-Up's room is a two-line scene, built with the same pipeline as any scene on the site:

export const BENCH_SCENE = [
  'scene foldup_bench "Fold-Up bench" template=interior seed=1 size=8 mood=day',
  'room bench rect -3 -2 6 4 floor=planks floorc=birch,oak wall=none',
].join('\n')

const prog = sc.parseScene(src)
const spec = sc.resolveScene(prog, (k) => protos.get(k)?.footprint ?? null)
const built = sc.buildScene(spec, protos, mats)       // root, background, fog, stats, update(), dispose()
scene.add(built.root)
// every frame: built.update(dt, [cam.x, cam.z], [target.x, target.z])

sc.sceneKeys(prog) lists every asset and seed a scene needs, so you can build the protos before you resolve it.

Pin every model in one table

export const ART = {
  toy_duck: { kind: 'lib', origin: 'new', slug: 'toy_duck', v: 1, seed: 1, size: [0.3, 0.3, 0.18] },
  bot_bolt: { kind: 'lib', origin: 'new', slug: 'helper_bot', v: 1, seed: 2, size: [0.7, 0.7, 0.95] },
  snail_van: { kind: 'lib', origin: 'reused', slug: 'snail_van', v: 1, seed: 1, size: [1.7, 3.1, 2.0] },
  tree_1: { kind: 'builtin', ref: 'nature/tree:lollipop', seed: 1, size: [3.6, 3.9, 4.1] },
} as const satisfies Record<string, ArtRef>

Always pin v: a saved seed of a pinned version never changes, while the latest version can. One seed of one script is a colourway, so helper_bot seeds 1, 2 and 3 are three different characters. The loader fetches every distinct model version in one tRPC call (models.scripts) and builds one proto per key with sc.makeCelProto.

Built-ins and people come from sc.makeBuiltinProto (person/any or an archetype, with idle and walk states).

Placeholders. A model that isn't made yet (v: null), a missing script or a compile error gets a stand-in, a clay box of the planned size, so the game always runs. Count them in your stats and list them in development.

Posed clones. A proto is the model at the origin in its rest pose. Static protos clone plainly and share geometry. Animated ones (bones or effects) need a skinned clone that copies the proto's pose each frame; once states hold their end pose.

The QA handle

Every game exposes window.__game through exposeQa from @celworld/play, on in development or with ?qa=1. Give it the state, the mode, the verbs a player has (each resolving when the UI settles), a skip, a sandbox grant and stats() returning frames per second, draw calls, triangles and placeholders. Browser automation and end-to-end tests drive the game through it.

Performance budgets

  • Measure with outlines on: the outline pass doubles inked meshes. Fold-Up's targets at 1440 × 900 after five seconds idle are 400 draw calls on the floor, 200 while packing, and 60 fps.
  • Clone static protos, share one posed proto between identical movers, and keep joints and effects off scenery.
  • Per model, the complexity budget caps draw calls at 2 to 8. Per scene, read cel scene check and cel shot scene.
  • The floor is a low-end iPad. Measure, don't assume.

Saves without accounts

Players have no accounts. Fold-Up keys saves on a guest cookie and re-validates every action on the server with the same reducers the client runs:

  1. The cookie (apps/web/server/player.ts): a UUID, httpOnly, 400 days. Server rendering reads it but never sets it.
  2. The context: ctx.player on every request; playerProcedure requires the cookie.
  3. The schema: a save row per player with a revision number and JSON state, shipped as a committed Prisma migration.
  4. The store: load the row, run the shared reducer, write back under a revision compare-and-swap. A stale revision is a conflict.
  5. The router: peek (safe during server rendering), load, and one zod-validated procedure per player verb.

A new game, in order

  1. A rules package in games/<name>/: no three.js, reducers the server can re-run, and the art table with v: null placeholders from day one.
  2. A stage in apps/web/src/viewport/games/<name>/stage.ts: the lease, materials, the environment scene, the art loader, stats() and dispose().
  3. A route in apps/web/src/app/games/<name>.tsx with the viewport, the HUD and the QA handle; add it to the routes and the games hub.
  4. Art: one agent run per wave. Check, shoot, save, pin v, and write an art test for the game's own contract (sizes, part names, states).
  5. Saves: a model, a migration, a store with a revision-checked mutate, a router on playerProcedure.
  6. Verify: bun run typecheck, bun run test, and drive /games/<name> through window.__game with stats inside the budget.