Skip to main content

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​

  1. The title screen, then chapter 1 starts: title, say and choice beats wait for a tap.
  2. 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.
  3. Back in the village, objective beats 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.
  4. reward beats grant items, scene: 'unlock' opens new buildings or crops, and the next chapter raises the stakes.
  5. 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​

FieldTypeDefaultMeaning
idstringrequiredURL-safe id. Also keys the save slot kingdom:<id>.
titlestringrequiredShown on the title screen.
descriptionstringrequiredShown on the title screen and the game's card.
badgestringnoneCard metadata.
themeKingdomThemesee belowColours, sky and fonts. accent is required.
resourcesResourceDef[]requiredThe currencies.
buildingsBuildingDef[]requiredWhat can be built.
cropsCropDef[][]What can be planted on plots.
unitsUnitDef[]requiredFighters: allies and enemies.
classesHeroClassDef[]required (may be empty)The hero's options. An empty list gives a default sword hero.
missionsMissionDef[]requiredThe sorties.
townTownDefsee belowVillage size and layout.
storyStoryrequiredSpeakers and chapters.
art{ sheet?: { source, cols, rows } }noneA sprite sheet of cut-outs (portraits, resource icons, crops). Cells are picked by frame.
soundsKingdomSoundsnoneMusic and effects. Without it the game is silent.
boardstring'kingdom-power'Leaderboard name for the kingdom's power.
saveVersionnumber1The save slot's version. Bump it with a migration when an existing save's shape changes.
migrationsRecord<number, (old) => unknown>nonemigrations[n] turns a version n-1 save into version n, passed to the save slot as is. See Saves and backend.
objectivesRecord<string, (town: TownState) => number>noneThe game's own counts for objective ids the module doesn't count, see Story.

theme​

FieldDefaultMeaning
accentrequiredPrimary 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.
fontsNunito black and bold{ display, ui? } from @/engine/ui/fonts. ui defaults to display when only display is given.

resources​

FieldTypeDefaultMeaning
idstringrequiredReferenced by costs, yields and rewards.
namestringrequiredDisplay name.
iconIconNamerequiredSF Symbol name, for example 'leaf.fill'.
framenumbernoneA cell of art.sheet, shown instead of the icon.
colorCSS colourtheme.accentIts colour in the HUD.
startnumber0What a new village holds.
capnumber0 (no cap)Storage cap.

buildings​

FieldTypeDefaultMeaning
idstringrequiredReferenced by build: objectives, prebuilt and unlock scenes.
namestringrequiredDisplay name.
shape'hall' | 'house' | 'farm' | 'tower' | 'workshop' | 'camp' | 'shrine' | 'wall' | 'well'requiredThe fallback shape drawn until a model is given.
descriptionstring''One line on the build card.
iconIconNamenoneBuild card icon.
framenumbernoneA cell of art.sheet for the build card.
footprint[number, number][1, 1]Lots wide and deep, 1 to 3 each.
costCostsrequiredLevel 1 price, per resource id.
buildSecondsnumberrequiredLevel 1 build time, 0 (instant) to 6 hours.
maxLevelnumber3Highest level, 1 to 10.
upgradeScalenumber1.8Level 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 }noneMakes amount times level of resource every seconds (3 s to a day), collected with a tap.
powernumber10Kingdom power per level, the leaderboard score.
troops{ unit, count }nonecount times level of that unit join the hero on every sortie (a barracks).
colors{ wall?, roof?, trim? }the theme'sFallback shape colours.
modelnumber (a required .glb)noneA building model, placed in its lot at its own proportions.
unlockedbooleantruefalse hides it until a scene: 'unlock' beat opens it.

crops​

FieldTypeDefaultMeaning
idstringrequiredReferenced by plant: and harvest: objectives.
namestringrequiredDisplay name.
secondsnumberrequiredPlanting to harvest, 3 s to a day.
yields{ resource, amount }requiredWhat a harvest gives. A crop yielding an unknown resource is dropped.
costCostsfreeSeed price.
icon, frameIconName, numbernoneIcon or sheet cell.
stagesnumber[]noneSheet cells shown as the crop grows, sprout to ripe. Without them the module draws growing stalks.
colorCSS colour'#9BD35A'Fallback crop colour.
unlockedbooleantruefalse hides it until a scene: 'unlock' beat opens it.

units​

FieldTypeDefaultMeaning
idstringrequiredReferenced by camps, escort and troops.
namestringrequiredDisplay name.
hpnumberrequiredHit points, at least 1.
damagenumberrequiredDamage per hit.
rangenumberrequiredAttack reach in metres, 0.8 to 12. Melee about 1.4, spear 2, bow 6.
cooldownnumberrequiredSeconds between attacks, 0.2 to 10.
speednumberrequiredMetres per second, 0.5 to 12.
colorCSS colourrequiredFallback capsule colour.
heightnumber1.4Height 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.
modelnumbernoneA rigged character .glb.
clipsRecord<string, number>noneExtra 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 runWhich clip plays when.
tintCSS colournoneMultiplied into the model's colours, so one model serves several teams.
held{ right?, left?, rightBone?, leftBone?, scale? }noneProp 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:

FieldTypeMeaning
idstringMust match an option id of the story's choice with id: 'class'.
namestringDisplay name.
detailstringOne line on the choice card.
icon, portraitIconName, PortraitShown on the choice card.

The first class is used until the player picks one. The pick is kept in the save.

missions​

FieldTypeDefaultMeaning
idstringrequiredReferenced by scene: 'sortie' and sortie: objectives.
namestringrequiredDisplay name.
sizenumber30Half-size of the field in metres, 12 to 80.
heroSpawn{ x, z }{ x: 0, z: size * 0.7 }Where the hero starts.
campsCampDef[]requiredEnemy camps, see below.
rewardsCostsrequiredGranted on victory.
groundCSS colourtheme.groundField colour.
fogbooleantrueFog on the field.
escortstring[][]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​

FieldTypeDefaultMeaning
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.
lotnumber4Metres 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? }noneA river band across the village at that row, width rows wide (default 1).
villagersVillagerDef[][]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 }.

BeatFieldsWaits for
titletext, sub?a tap
sayspeaker, texta tap (the first tap finishes the typing). Skip jumps to the next choice, objective or scene.
choiceid, title, options: { id, label, detail, icon?, portrait? }[], confirm?the confirm button. id: 'class' sets the hero class.
objectiveid, text, target? (default 1), hint?the town's counts (below), or the game's own count in objectives, reaching target
rewardtitle?, items: { resource, amount }[]the button; the items are granted then
scenescene, args?nothing, see below

The scene beats the module performs:

sceneargsDoes
sortiemissionstarts that mission (default: the first)
unlockbuilding or cropunlocks it and shows a toast
focuscol, rowpans the village camera to that lot
townnonereturns to the village

Objective ids the module counts by itself:

IdCounts
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
cleardebris cleared
sortie:<mission>sorties of that mission won
class1 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:

KeyPlays
musicthe village theme, looping
battlethe sortie theme, crossfading in on every sortie
taptaps on dialogue and buttons
builda build or upgrade starting or finishing
harvesta harvest
collectcollecting from a producer or a reward
hita hit landing
bowa ranged swing (falls back to hit)
whoosha melee swing (falls back to hit)
stepthe hero's footsteps on a sortie
gruntthe hero taking a hit
groanan enemy going down
horna camp waking
victorya sortie won
defeata sortie lost

What you can customise​

WhatHow
Economy and pacingresources, building cost, buildSeconds, upgradeScale, produces, crop seconds and yields, mission rewards. Make every chapter's objectives affordable from what the chapter gives.
Fightsunits, classes, camps per mission, escort, troops.
The villagetown size, prebuilt buildings, plots, debris, paths, water, villagers.
The storystory.speakers and story.chapters, plus objectives for objective ids of your own. Keep lines under about 140 characters; the box types them out.
Looktheme, building colors, model on buildings and units, art.sheet and frame for icons and portraits.
Soundsounds.
RulesThe 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 match classes ids, 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 saveVersion without a migration for the new version only fills in missing fields; anything renamed or reshaped needs migrations. 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.