Writing a module
Write a module when one genre should be quick and reliable for every new game: a config in, a complete game out. If only one game needs the genre, write that game from the engine pieces instead (see Game modules). A module is the same code moved into src/engine/modules/<name>/ with its knobs pulled out into a config.
This page lists the conventions the racing, puzzle, shooter and kingdom modules share. Follow them so the module reads like the others to people and to coding agents.
File layout
Everything lives in templates/crescent-game/src/engine/modules/<name>/:
| File | Holds | In the existing modules |
|---|---|---|
index.ts | the public exports, and nothing else | every module |
types.ts | the Config the game writes and the ResolvedConfig with every default filled in | racing/types.ts, shooter/types.ts, kingdom/types.ts (puzzle keeps them in config.ts) |
config.ts | resolve<Name>Config(config): fills defaults, clamps ranges, drops bad references | resolveRaceConfig, resolvePuzzleConfig, resolveShooterConfig, resolveKingdomConfig |
| pure rule files | the game's rules as plain TypeScript, no React, no React Native | raceState.ts, vehicle.ts, track.ts; packs/, session.ts, goals.ts; match.ts, maps.ts, pickups.ts, snapshot.ts; town.ts, sortie.ts, objectives.ts |
<Name>Game.tsx | the screen and define<Name>Game | RaceGame.tsx, PuzzleGame.tsx, ShooterGame.tsx, KingdomGame.tsx |
<Name>Hud.tsx, screens | the HUD and the title, pause and results screens | RaceHud.tsx, PuzzleScreens.tsx, ShooterHud.tsx, KingdomHud.tsx |
| meshes or render | the scene pieces | TrackMeshes.tsx, Car.tsx; render.ts; ShooterMeshes.tsx; meshes.tsx, TownScene.tsx, SortieScene.tsx |
*.test.ts | unit tests for the pure files | puzzle/levels.test.ts, puzzle/packs.test.ts, shooter/shooter.test.ts, kingdom/kingdom.test.ts |
Inside the module, import engine pieces by relative path (../../render/EngineCanvas, ../../input/TouchControls). Games import the module by its own path, @/engine/modules/<name>; modules are not re-exported from @/engine.
The define function
One entry point, named define<Name>Game, takes the config and returns a GameDefinition. Both types come from src/engine/core/types.ts (inside a module, import type { GameDefinition, GameScreenProps } from '../../core/types'; games get them from @/engine).
The shape every module uses, shown here with the shooter's:
export const defineShooterGame = (config: ShooterConfig): GameDefinition => {
const resolved = resolveShooterConfig(config);
const Component = (props: GameScreenProps) => <ShooterGame config={resolved} {...props} />;
Component.displayName = 'ShooterGameScreen';
return {
id: resolved.id,
title: resolved.title,
description: resolved.description,
badge: resolved.badge,
accent: resolved.accent,
Component,
};
};
- Resolve the config once, outside the component.
ComponentreceivesonExitfromGameScreenProps. Every exit control in the module calls it.- Pick a sensible
accentfrom the config (the racing module uses the car's accent, the puzzle moduletheme.accent). - Keep anything that must exist once per game (a save slot definition, as the kingdom module does with
defineSave) next to the resolve call, not inside the component.
The game then writes one file:
// src/game/index.tsx
import { defineShooterGame } from '@/engine/modules/shooter';
export default defineShooterGame({ id: 'yard-brawl', title: 'Yard Brawl', description: 'Everyone against everyone.' });
Config types
- Only identity fields are required (
id,title,description) where the genre allows. The kingdom module also requires its content tables, because a kingdom with no buildings or story is not a game. - Put the range and the default in each field's doc comment, for example
/** Road width in metres. 8-16. Default 11. */. Agents read these comments. - Use string unions for choices (
'ffa' | 'tdm' | ...) and export the list when it is useful at runtime (SHOOTER_MODES,SHOOTER_MAPS). - Group fields by topic (
track,car,opponents,theme;rules,map,player,bots,pickups) rather than one flat object. - The resolver clamps every number into a range that plays well and falls back on unknown values, so a config written in a hurry still runs. Drop references that point nowhere (the kingdom resolver warns about them in development) instead of crashing.
- Assets come in as
require(...)numbers on optional fields (model,sounds,art.sheet). The module always has a fallback: a procedural shape, placeholder sounds or silence, an icon instead of a sprite.
The screen
- A 3D module renders an
EngineCanvasinside anEngineErrorBoundary, runs its simulation inuseFrame, and writes per-frame numbers to Reanimated shared values so React does not re-render every frame. A 2D module rendersGameCanvas2D. See Canvas and rendering and 2D layer. - Use the engine's controls (
TouchControls,CharacterControls,Joystick). They already answer to the keyboard in a desktop browser. See Input. - Leaderboards, presence and rooms go through the net hooks with
GAME_BACKEND_URL, so the module works with and without a backend. Progress goes through saves. - Nothing platform-specific in the module. What differs between web, iOS and Android belongs in the engine's platform layer (
*.web.tsfiles,Platform.OS).
Dev and test hooks
Every module has the same three, so automation can drive any of them:
- A telemetry line with
testIDhud-debug, shown only when__DEV__is true. - An AUTO button (
btn-autopilot, orbtn-autoplayin the puzzle module) that plays the game by itself, also only in development. - A
testIDand anaccessibilityLabelon every control, andgame-<id>on the root view.
Tests
Put the rules in pure files and test them with Node's test runner next to the code, as *.test.ts:
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { resolveKingdomConfig } from './config';
npm test in the template runs every src/engine/**/*.test.ts. Test at least: the resolver's defaults and clamps, each rule, the end-of-round conditions, and that the AUTO player reaches the end. The shooter's tests also walk every map preset to check that bots can reach every objective.
Docs, skill and example
A module is not done until people and agents can find it and use it:
| Piece | Where | What |
|---|---|---|
| Agent reference | templates/crescent-game/docs/<name>-module.md | every config field with type, range and default, a copy-ready config, backend needs |
| Skill | templates/crescent-game/.claude/skills/<genre>-game/SKILL.md | how to turn a player's words into a config: tables of "player says" to fields, naming, difficulty, a checklist |
| Router row | templates/crescent-game/.claude/skills/game-router/SKILL.md | the trigger words, the route, what to start from, and what the module cannot do |
| Agent instructions | the template's CLAUDE.md and AGENTS.md ("What you can build") and docs/engine-api.md (the module list) | one entry pointing at the doc, the skill and the example |
| Example | templates/crescent-game/examples/<game>/index.tsx | a complete, self-contained game on the module that a game can copy over src/game/index.tsx |
| Public page | docs/modules/<name>.md on this site | the page format the other module pages use |
If the module uses the backend, document which leaderboard names it submits to (and add them, or a "prefix-*" family, to the scaffold's BOARDS if every game of the module uses them), whether it needs rooms, and what it saves. See the backend overview.
Checklist
src/engine/modules/<name>/withindex.ts, the config types,config.ts, the pure rules, the screen anddefine<Name>Game.- Fallbacks for every asset field.
hud-debug, AUTO,testIDs, an exit that callsonExit.- Tests for the pure parts;
npm testandnpm run typecheckpass. - The game runs on the web (
npm run web) and on a phone build. - Agent doc, skill, router row,
CLAUDE.md/AGENTS.md/engine-api.mdentries, an example, a public page. - Backend entries if the module uses leaderboards or rooms.
For the Moonspell app, the hosted builder (Nova) also has a list of what it can build in agent/instructions.ts; add the module there too if you contribute to the app. How the app picks up engine changes is on the Moonspell pages.