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
| Layer | Fold-Up | Rule |
|---|---|---|
| Rules package | games/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 |
| Art | packages/engine/src/dsl/scripts/foldup/*.cel | Saved to the library and pinned by version in the art table |
| Stage | apps/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 HUD | apps/web/src/app/games/foldup.tsx | The route module, HUD cards, copy, sound, the QA hook |
| Server | apps/web/server/routers/foldup.ts | Guest 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 checkandcel 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:
- The cookie (
apps/web/server/player.ts): a UUID, httpOnly, 400 days. Server rendering reads it but never sets it. - The context:
ctx.playeron every request;playerProcedurerequires the cookie. - The schema: a save row per player with a revision number and JSON state, shipped as a committed Prisma migration.
- The store: load the row, run the shared reducer, write back under a revision compare-and-swap. A stale revision is a conflict.
- The router:
peek(safe during server rendering),load, and one zod-validated procedure per player verb.
A new game, in order
- A rules package in
games/<name>/: no three.js, reducers the server can re-run, and the art table withv: nullplaceholders from day one. - A stage in
apps/web/src/viewport/games/<name>/stage.ts: the lease, materials, the environment scene, the art loader,stats()anddispose(). - A route in
apps/web/src/app/games/<name>.tsxwith the viewport, the HUD and the QA handle; add it to the routes and the games hub. - 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). - Saves: a model, a migration, a store with a revision-checked mutate, a router on
playerProcedure. - Verify:
bun run typecheck,bun run test, and drive/games/<name>throughwindow.__gamewith stats inside the budget.