Skip to main content

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>/:

FileHoldsIn the existing modules
index.tsthe public exports, and nothing elseevery module
types.tsthe Config the game writes and the ResolvedConfig with every default filled inracing/types.ts, shooter/types.ts, kingdom/types.ts (puzzle keeps them in config.ts)
config.tsresolve<Name>Config(config): fills defaults, clamps ranges, drops bad referencesresolveRaceConfig, resolvePuzzleConfig, resolveShooterConfig, resolveKingdomConfig
pure rule filesthe game's rules as plain TypeScript, no React, no React NativeraceState.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.tsxthe screen and define<Name>GameRaceGame.tsx, PuzzleGame.tsx, ShooterGame.tsx, KingdomGame.tsx
<Name>Hud.tsx, screensthe HUD and the title, pause and results screensRaceHud.tsx, PuzzleScreens.tsx, ShooterHud.tsx, KingdomHud.tsx
meshes or renderthe scene piecesTrackMeshes.tsx, Car.tsx; render.ts; ShooterMeshes.tsx; meshes.tsx, TownScene.tsx, SortieScene.tsx
*.test.tsunit tests for the pure filespuzzle/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.
  • Component receives onExit from GameScreenProps. Every exit control in the module calls it.
  • Pick a sensible accent from the config (the racing module uses the car's accent, the puzzle module theme.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 EngineCanvas inside an EngineErrorBoundary, runs its simulation in useFrame, and writes per-frame numbers to Reanimated shared values so React does not re-render every frame. A 2D module renders GameCanvas2D. 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.ts files, Platform.OS).

Dev and test hooks​

Every module has the same three, so automation can drive any of them:

  • A telemetry line with testID hud-debug, shown only when __DEV__ is true.
  • An AUTO button (btn-autopilot, or btn-autoplay in the puzzle module) that plays the game by itself, also only in development.
  • A testID and an accessibilityLabel on every control, and game-<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:

PieceWhereWhat
Agent referencetemplates/crescent-game/docs/<name>-module.mdevery config field with type, range and default, a copy-ready config, backend needs
Skilltemplates/crescent-game/.claude/skills/<genre>-game/SKILL.mdhow to turn a player's words into a config: tables of "player says" to fields, naming, difficulty, a checklist
Router rowtemplates/crescent-game/.claude/skills/game-router/SKILL.mdthe trigger words, the route, what to start from, and what the module cannot do
Agent instructionsthe 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
Exampletemplates/crescent-game/examples/<game>/index.tsxa complete, self-contained game on the module that a game can copy over src/game/index.tsx
Public pagedocs/modules/<name>.md on this sitethe 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​

  1. src/engine/modules/<name>/ with index.ts, the config types, config.ts, the pure rules, the screen and define<Name>Game.
  2. Fallbacks for every asset field.
  3. hud-debug, AUTO, testIDs, an exit that calls onExit.
  4. Tests for the pure parts; npm test and npm run typecheck pass.
  5. The game runs on the web (npm run web) and on a phone build.
  6. Agent doc, skill, router row, CLAUDE.md / AGENTS.md / engine-api.md entries, an example, a public page.
  7. 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.