Kingdom module
defineKingdomGame(config) makes a story-led town builder. The player grows a village (buildings on timers, producers to collect from, crops to plant and harvest, debris to clear) and rides out on joystick sorties with a hero and a squad to clear enemy camps. A story of chapters runs through both: dialogue with portraits, choices, objectives the town answers by itself, rewards and unlocks. Use it for "a kingdom I build and defend", "a farm with a story" or "a village and raids".
import { defineKingdomGame } from '@/engine/modules/kingdom';
import type { KingdomConfig } from '@/engine/modules/kingdom';
Source: templates/crescent-game/src/engine/modules/kingdom/. It uses the engine's story kit, the floating Joystick (Input), TypeGPU grass (GPU effects), generated or packaged models (Models and scenery) and saves.
The loop
- The title screen, then chapter 1 starts:
title,sayandchoicebeats wait for a tap. - A
scene: 'sortie'beat starts a fight: the hero and the squad walk a foggy field with the joystick, camps wake as they come near, and a Victory sheet grants the mission's rewards. - Back in the village,
objectivebeats wait for the town's own counts (harvest, build, collect, clear, hold, upgrade, win a sortie). A met objective moves the story on by itself. rewardbeats grant items,scene: 'unlock'opens new buildings or crops, and the next chapter raises the stakes.- After the last beat the village keeps running: timers, producers, crops and sorties stay available.
The smallest complete game
Every kingdom needs a theme accent, resources, buildings, units, hero classes, missions and a story. This is about as short as that gets:
// src/game/index.tsx
import { defineKingdomGame } from '@/engine/modules/kingdom';
export default defineKingdomGame({
id: 'small-holding',
title: 'Small Holding',
description: 'Drive the raiders off, then grow wheat and build a farm.',
theme: { accent: '#27B3AD' },
resources: [
{ id: 'grain', name: 'Grain', icon: 'leaf.fill', start: 40 },
{ id: 'wood', name: 'Wood', icon: 'tree.fill', start: 60 },
],
buildings: [
{ id: 'hall', name: 'Town hall', shape: 'hall', footprint: [2, 2], cost: { wood: 200 }, buildSeconds: 60 },
{ id: 'farm', name: 'Farm', shape: 'farm', cost: { wood: 25 }, buildSeconds: 12, produces: { resource: 'grain', amount: 10, seconds: 25 } },
],
crops: [{ id: 'wheat', name: 'Wheat', seconds: 20, yields: { resource: 'grain', amount: 15 } }],
units: [{ id: 'raider', name: 'Raider', hp: 30, damage: 5, range: 1.4, cooldown: 1, speed: 3.9, color: '#B33A2E' }],
classes: [{ id: 'knight', name: 'Knight', detail: 'Sword and shield.', hp: 150, damage: 18, range: 1.9, cooldown: 0.55, speed: 5.4, weapon: 'blade', color: '#2E4FA8' }],
missions: [{ id: 'fields', name: 'The fields', camps: [{ x: 0, z: -8, units: ['raider', 'raider', 'raider'] }], rewards: { grain: 60, wood: 60 } }],
town: {
prebuilt: [{ building: 'hall', lot: [3, 1] }],
plots: [[1, 6], [2, 6]],
},
story: {
speakers: [{ id: 'elder', name: 'Elder Bryn', role: 'Village elder' }],
chapters: [
{
id: 'raiders',
title: 'Raiders',
beats: [
{ kind: 'title', text: 'Chapter I', sub: 'Raiders' },
{ kind: 'say', speaker: 'elder', text: 'Raiders camp past the fields. Drag your thumb anywhere to move.' },
{ kind: 'scene', scene: 'sortie', args: { mission: 'fields' } },
{ kind: 'objective', id: 'sortie:fields', text: 'Drive off the raiders' },
{ kind: 'objective', id: 'harvest:wheat', text: 'Harvest wheat', target: 15, hint: 'Tap a plot to plant' },
{ kind: 'objective', id: 'build:farm', text: 'Build a farm', hint: 'Build, then tap a green lot' },
{ kind: 'say', speaker: 'elder', text: 'The village will eat this winter.' },
],
},
],
},
});
Until models and portraits are given, the module draws fallback shapes: a procedural building per shape, capsules with weapons for fighters, and SF Symbol icons for resources and portraits. examples/oath-of-ashvale/index.tsx is a complete game with two chapters, rigged characters, generated buildings, a sprite sheet of portraits and icons, and a full sound set.
Controls
- Village: tap a lot, a building, a plot or debris to open its panel; drag to pan; pinch to zoom. The build button opens the build sheet.
- Sortie: the floating joystick appears under the thumb anywhere on the screen. The hero and the squad attack on their own. In a desktop browser, WASD or the arrows move.
Config reference
resolveKingdomConfig fills defaults, clamps ranges and drops references that point nowhere (an unknown resource in a cost, an unknown unit in a camp), with a warning in development.
Top level
| Field | Type | Default | Meaning |
|---|---|---|---|
id | string | required | URL-safe id. Also keys the save slot kingdom:<id>. |
title | string | required | Shown on the title screen. |
description | string | required | Shown on the title screen and the game's card. |
badge | string | none | Card metadata. |
theme | KingdomTheme | see below | Colours, sky and fonts. accent is required. |
resources | ResourceDef[] | required | The currencies. |
buildings | BuildingDef[] | required | What can be built. |
crops | CropDef[] | [] | What can be planted on plots. |
units | UnitDef[] | required | Fighters: allies and enemies. |
classes | HeroClassDef[] | required (may be empty) | The hero's options. An empty list gives a default sword hero. |
missions | MissionDef[] | required | The sorties. |
town | TownDef | see below | Village size and layout. |
story | Story | required | Speakers and chapters. |
art | { sheet?: { source, cols, rows } } | none | A sprite sheet of cut-outs (portraits, resource icons, crops). Cells are picked by frame. |
sounds | KingdomSounds | none | Music and effects. Without it the game is silent. |
board | string | 'kingdom-power' | Leaderboard name for the kingdom's power. |
saveVersion | number | 1 | The save slot's version. Bump it with a migration when an existing save's shape changes. |
migrations | Record<number, (old) => unknown> | none | migrations[n] turns a version n-1 save into version n, passed to the save slot as is. See Saves and backend. |
objectives | Record<string, (town: TownState) => number> | none | The game's own counts for objective ids the module doesn't count, see Story. |
theme
| Field | Default | Meaning |
|---|---|---|
accent | required | Primary buttons, the quest badge, the hero ring. |
onAccent | '#FFFFFF' | Text on accent buttons. |
panel | '#FFF8EE' | Dialogue box, sheets and panels. |
text | '#2A2430' | Text. |
dim | '#7A7189' | Secondary text. |
ground | '#6FA84F' | Field colour. |
grass | '#7FBA5A' | The village's grass. |
path | '#C9B48A' | Paths between lots. |
water | '#4FA3D9' | The water row. |
sky | 'day' | An environment preset: 'day', 'sunset', 'night', 'overcast', 'neon' or 'space'. |
wall | '#E6D8BF' | Fallback building walls. |
roof | '#8A4A2E' | Fallback building roofs. |
trim | '#4A3A2E' | Fallback building trim. |
danger | '#FF3B3B' | Enemy camp rings. |
banner | '#E2A63A' | Victory and chapter ribbons. |
fog | '#DCE9DF' | Sortie fog and the canvas background. |
fonts | Nunito black and bold | { display, ui? } from @/engine/ui/fonts. ui defaults to display when only display is given. |
resources
| Field | Type | Default | Meaning |
|---|---|---|---|
id | string | required | Referenced by costs, yields and rewards. |
name | string | required | Display name. |
icon | IconName | required | SF Symbol name, for example 'leaf.fill'. |
frame | number | none | A cell of art.sheet, shown instead of the icon. |
color | CSS colour | theme.accent | Its colour in the HUD. |
start | number | 0 | What a new village holds. |
cap | number | 0 (no cap) | Storage cap. |
buildings
| Field | Type | Default | Meaning |
|---|---|---|---|
id | string | required | Referenced by build: objectives, prebuilt and unlock scenes. |
name | string | required | Display name. |
shape | 'hall' | 'house' | 'farm' | 'tower' | 'workshop' | 'camp' | 'shrine' | 'wall' | 'well' | required | The fallback shape drawn until a model is given. |
description | string | '' | One line on the build card. |
icon | IconName | none | Build card icon. |
frame | number | none | A cell of art.sheet for the build card. |
footprint | [number, number] | [1, 1] | Lots wide and deep, 1 to 3 each. |
cost | Costs | required | Level 1 price, per resource id. |
buildSeconds | number | required | Level 1 build time, 0 (instant) to 6 hours. |
maxLevel | number | 3 | Highest level, 1 to 10. |
upgradeScale | number | 1.8 | Level n costs cost times upgradeScale to the power n minus 1, and takes as long. 1 to 4. costAt(building, level) computes it. |
produces | { resource, amount, seconds } | none | Makes amount times level of resource every seconds (3 s to a day), collected with a tap. |
power | number | 10 | Kingdom power per level, the leaderboard score. |
troops | { unit, count } | none | count times level of that unit join the hero on every sortie (a barracks). |
colors | { wall?, roof?, trim? } | the theme's | Fallback shape colours. |
model | number (a required .glb) | none | A building model, placed in its lot at its own proportions. |
unlocked | boolean | true | false hides it until a scene: 'unlock' beat opens it. |
crops
| Field | Type | Default | Meaning |
|---|---|---|---|
id | string | required | Referenced by plant: and harvest: objectives. |
name | string | required | Display name. |
seconds | number | required | Planting to harvest, 3 s to a day. |
yields | { resource, amount } | required | What a harvest gives. A crop yielding an unknown resource is dropped. |
cost | Costs | free | Seed price. |
icon, frame | IconName, number | none | Icon or sheet cell. |
stages | number[] | none | Sheet cells shown as the crop grows, sprout to ripe. Without them the module draws growing stalks. |
color | CSS colour | '#9BD35A' | Fallback crop colour. |
unlocked | boolean | true | false hides it until a scene: 'unlock' beat opens it. |
units
| Field | Type | Default | Meaning |
|---|---|---|---|
id | string | required | Referenced by camps, escort and troops. |
name | string | required | Display name. |
hp | number | required | Hit points, at least 1. |
damage | number | required | Damage per hit. |
range | number | required | Attack reach in metres, 0.8 to 12. Melee about 1.4, spear 2, bow 6. |
cooldown | number | required | Seconds between attacks, 0.2 to 10. |
speed | number | required | Metres per second, 0.5 to 12. |
color | CSS colour | required | Fallback capsule colour. |
height | number | 1.4 | Height in metres of the capsule and the model. 1.9 to 2.2 reads as a brute. |
weapon | 'blade' | 'spear' | 'bow' | 'blade' | Fallback weapon and attack style. |
model | number | none | A rigged character .glb. |
clips | Record<string, number> | none | Extra animation files by name. A file with several clips exposes them as name.Clip, for example move.Running_A. |
animations | { idle?, run?, attack?, death? } | idle and run | Which clip plays when. |
tint | CSS colour | none | Multiplied into the model's colours, so one model serves several teams. |
held | { right?, left?, rightBone?, leftBone?, scale? } | none | Prop models attached to the rig's hand bones: a sword, a shield, a bow. |
classes
The hero's options. Same fighting fields as a unit (hp, damage, range, cooldown, speed from 1 to 12, weapon, color, model, clips, animations, tint, held; weapon is required here), plus:
| Field | Type | Meaning |
|---|---|---|
id | string | Must match an option id of the story's choice with id: 'class'. |
name | string | Display name. |
detail | string | One line on the choice card. |
icon, portrait | IconName, Portrait | Shown on the choice card. |
The first class is used until the player picks one. The pick is kept in the save.
missions
| Field | Type | Default | Meaning |
|---|---|---|---|
id | string | required | Referenced by scene: 'sortie' and sortie: objectives. |
name | string | required | Display name. |
size | number | 30 | Half-size of the field in metres, 12 to 80. |
heroSpawn | { x, z } | { x: 0, z: size * 0.7 } | Where the hero starts. |
camps | CampDef[] | required | Enemy camps, see below. |
rewards | Costs | required | Granted on victory. |
ground | CSS colour | theme.ground | Field colour. |
fog | boolean | true | Fog on the field. |
escort | string[] | [] | Ally unit ids that follow the hero, on top of the barracks' troops. |
A camp is { x, z, units, radius?, banner? }: a red ring at (x, z) with these unit ids in it. radius defaults to 3.5 (1.5 to 8). A camp sleeps until the hero or an ally comes within radius + 2.5 m. banner is a label over the ring.
town
| Field | Type | Default | Meaning |
|---|---|---|---|
size | [cols, rows] | [8, 10] | Lots across and down, 5 to 14 each. Columns grow to the right of the screen, rows go down it. |
lot | number | 4 | Metres per lot, 2.5 to 8. |
prebuilt | { building, lot, level? }[] | [] | Buildings standing at the start. Entries naming an unknown building are dropped. |
plots | [col, row][] | [] | Lots that are crop plots. |
debris | { kind, lot, seconds?, yields? }[] | [] | Trees, rocks, rubble or brush to clear. seconds defaults to 5. |
paths | [col, row][][] | [] | Paths drawn on the ground, as lot-to-lot polylines. |
water | { row, width? } | none | A river band across the village at that row, width rows wide (default 1). |
villagers | VillagerDef[] | [] | Rigged models that walk a path back and forth: { model, clips?, walk?, idle?, tint?, height?, path?, speed? }, walking at 1.2 m/s by default. |
story
speakers ({ id, name, role?, color?, portrait?, side? }) and chapters ({ id, title, beats }), the engine's story kit types. A portrait is a sheet cell ({ source, cols, rows, frame }) or { icon }.
| Beat | Fields | Waits for |
|---|---|---|
title | text, sub? | a tap |
say | speaker, text | a tap (the first tap finishes the typing). Skip jumps to the next choice, objective or scene. |
choice | id, title, options: { id, label, detail, icon?, portrait? }[], confirm? | the confirm button. id: 'class' sets the hero class. |
objective | id, text, target? (default 1), hint? | the town's counts (below), or the game's own count in objectives, reaching target |
reward | title?, items: { resource, amount }[] | the button; the items are granted then |
scene | scene, args? | nothing, see below |
The scene beats the module performs:
scene | args | Does |
|---|---|---|
sortie | mission | starts that mission (default: the first) |
unlock | building or crop | unlocks it and shows a toast |
focus | col, row | pans the village camera to that lot |
town | none | returns to the village |
Objective ids the module counts by itself:
| Id | Counts |
|---|---|
build:<building> | buildings of that kind standing |
upgrade:<building>:<n> | 1 once one reaches level n |
plant:<crop> | plantings |
harvest:<crop> | units harvested |
collect:<resource> | units collected from producers |
hold:<resource> | the current amount in the wallet |
clear | debris cleared |
sortie:<mission> | sorties of that mission won |
class | 1 once a hero class was picked |
Any other id needs the game's own count in objectives: a function of the village (TownState: wallet, buildings, plots, stats, heroClass and more) that returns the objective's progress. It is read twice a second like the built-in ones and compared with target. Without one, the objective never completes.
objectives: {
feast: (town) => (town.wallet.wheat >= 300 && town.buildings.some((b) => b.kind === 'hall' && b.level >= 2) ? 1 : 0),
'big-village': (town) => town.buildings.filter((b) => b.level >= 1).length, // with target: 8
},
The function returns a total, not an increment. A count that throws or returns something other than a number is skipped. A count for a built-in id replaces the module's.
Sounds
Every key of sounds is optional and takes a required file:
| Key | Plays |
|---|---|
music | the village theme, looping |
battle | the sortie theme, crossfading in on every sortie |
tap | taps on dialogue and buttons |
build | a build or upgrade starting or finishing |
harvest | a harvest |
collect | collecting from a producer or a reward |
hit | a hit landing |
bow | a ranged swing (falls back to hit) |
whoosh | a melee swing (falls back to hit) |
step | the hero's footsteps on a sortie |
grunt | the hero taking a hit |
groan | an enemy going down |
horn | a camp waking |
victory | a sortie won |
defeat | a sortie lost |
What you can customise
| What | How |
|---|---|
| Economy and pacing | resources, building cost, buildSeconds, upgradeScale, produces, crop seconds and yields, mission rewards. Make every chapter's objectives affordable from what the chapter gives. |
| Fights | units, classes, camps per mission, escort, troops. |
| The village | town size, prebuilt buildings, plots, debris, paths, water, villagers. |
| The story | story.speakers and story.chapters, plus objectives for objective ids of your own. Keep lines under about 140 characters; the box types them out. |
| Look | theme, building colors, model on buildings and units, art.sheet and frame for icons and portraits. |
| Sound | sounds. |
| Rules | The town rules (town.ts) and the sortie simulation (sortie.ts) are pure TypeScript with tests, exported for tools and tests: createTown, place, upgrade, tick, collect, plant, harvest, startClear, power, troopsOf, createSortie, stepSortie, objectiveValue, autoObjectives and more. The scene pieces (BuildingShapeMesh, FighterMesh, CampRing, PlotMesh, DebrisMesh, ReadyMarker) are exported too. |
The kingdom's building blocks also work in any game on their own: the Joystick, the story kit's DialogueBox, ChoiceCards and RewardSheet, Grass and LivelyGround, Water, SheetImage and Billboard. See Input, Story, GPU effects and Models and scenery.
Saves and backend
The village, wallet, timers, story position and hero class live in one save slot, kingdom:<id>. Timers are absolute timestamps, so time passes while the app is closed. With a backend the slot syncs to the player's other devices (Saves sync).
Fields the module adds later reach old saves by themselves. When a game changes the shape of what an existing village keeps, bump saveVersion and add a migration: migrations[n] gets a version n-1 save ({ town, story, startedAt, sessions }) and returns version n. They run in order, each on a copy (Saves). kingdomSaveSpec(config) returns the slot's spec, for testing a migration with defineSave and memoryStore().
saveVersion: 2,
migrations: { 2: (old) => ({ ...old, town: old.town && { ...old.town, wallet: { ...old.town.wallet, stone: 0 } } }) },
With GAME_BACKEND_URL set, the kingdom's power is posted to the board leaderboard (default kingdom-power), and the title shows how many are playing. Power is the sum of each building's power times its level, plus 2 per debris cleared and 25 per sortie won. The backend scaffold's BOARDS does not list kingdom-power: add it, for example "kingdom-power": { order: "desc", min: 0, max: 1_000_000 }, or the score stays on the phone. See the backend overview.
Dev and testing
npm test runs kingdom.test.ts: config defaults, placing, building, upgrading, crops, debris, power, objectives (built-in and the game's own), the save slot and its migrations, and the sortie simulation.
In development builds the HUD shows a telemetry line (hud-debug) and an AUTO button (btn-autopilot). In the village AUTO answers dialogue, picks the first class, plants, harvests, collects, builds what the objective asks and clears debris; on a sortie it walks the hero into the nearest camp.
Other testIDs include title-screen, btn-play, chapter-title, quest-tracker, resource-bar, res-<id>, btn-build, build-sheet, build-<id>, building-panel, btn-upgrade, btn-collect, plot-panel, btn-plant-<id>, btn-harvest, debris-panel, btn-clear, btn-sortie, hud-enemies, hud-hp, victory-sheet, defeat-sheet, btn-retry, btn-pause, pause-sheet and hud-power.
Gotchas
- The story is required, and it is the only guide the player gets. A chapter with no objective or sortie leaves the player in the village with nothing to do but tap.
- The hero class choice must have
id: 'class'and option ids that matchclassesids, or the pick is ignored. - Objectives read the town's running totals, so work done before the objective appears already counts. A
hold:objective reads the wallet, so spending can undo it. - Costs, yields and rewards that name an unknown resource are dropped silently in production.
- Bumping
saveVersionwithout a migration for the new version only fills in missing fields; anything renamed or reshaped needsmigrations. Adding a field to the save needs neither (see Saves). - Not in the module: alliances, collectible heroes, player-versus-player, a shared world map and tower placement.