The objective loop is what turns the forest sandbox into a game: a player who clicks New Game is given a goal — conquer the worlds — surfaced on the HUD, and the run ends in an explicit win or lose screen with a Restart button. Score (kills + loot) is tracked throughout.
The campaign model (caravan quotas per world, sequential unlock, win when every available world is conquered) is specified in ADR 0005. This guide documents the implementation.
App/run flow is the appSlice phase machine. The
objective loop adds two end-of-run phases:
menu → playing ⇄ paused
│
├── every available world conquered ──▶ won ──▶ (Restart → playing | Quit → menu)
└── player HP reaches 0 ────────────────▶ lost ──▶ (Restart → playing | Quit → menu)
winGame/loseGameonly transition fromplaying— an already finished run never re-triggers, and a finished run cannot be paused.- On
won/lostthe live zone scene is unmounted:GameCanvaskeeps a scene only whileplaying/paused, so the win/lose overlay sits above the menu engine scene. Restart bumps the run and boots a fresh zone scene from scratch (the capsule, raided caravans, and corpses all reset with it).
Conquest is data-driven and reads three pieces of world data, all co-located
with ZONES in src/game/world/zones.ts:
| Data | Meaning |
|---|---|
ZONE_CARAVAN_QUOTAS |
Caravans to raid to conquer a world — forest 3, human-lands 5, empire 6, mountains 8. |
ZONE_CONQUEST_ORDER |
Campaign sequence; a world unlocks once the previous one is conquered. |
ZONES[id].status |
available (has a playable scene) vs locked (empire/mountains, no scene yet). |
The win condition reads ZONES.status — there is no hardcoded world count, so
the campaign auto-extends to four worlds the moment empire and mountains flip to
available and ship their scenes.
The decisions — is a world conquered, which worlds are unlocked, and is the run
still going, won, or lost? — are pure functions with no Redux/React/Babylon
dependency, so they are exhaustively unit-testable
(src/game/objective/objectiveMachine.ts):
isZoneConquered(zoneId, raidedByZone, quotas) // → boolean
unlockedZoneIds(conquestOrder, raidedByZone, quotas) // → unlocked prefix
evaluateOutcome({ raidedByZone, quotas, availableZoneIds, playerDead })
// → 'playing' | 'won' | 'lost'- Conquered — caravans raided in a zone reach its quota; an unknown zone (no quota) is never conquered.
- Unlocked — walking
ZONE_CONQUEST_ORDER, the first world is always unlocked and each next world unlocks once the prior is conquered. This is progression gating only;status(does the world have a scene?) is intersected separately by the world map. - Win = every
availableworld is conquered. An empty available set can never win (guards a misconfigured zone table from declaring an instant victory). Death takes priority over victory, keepingevaluateOutcometotal.
The App layer feeds these live state each frame (via the effects in App.tsx)
and drives the appSlice phase from the result.
Per-run progress lives in gameSlice:
| Field | Meaning |
|---|---|
caravansRaidedByZone |
Caravans raided this run per zone — the conquest progress (Record<zoneId, number>). |
caravansRaided |
Flat total across all zones — informational / score path only. |
kills |
Soldiers defeated this run. |
score |
Running score shown in the HUD (see below). |
Score accumulates as the run plays: KILL_SCORE (10) per soldier defeated
plus the loot points (item count) of each caravan raided. Raids beyond a
world's quota still count and still score — pure farming.
Actions: recordKill, raidCaravan({ zoneId, lootPoints }),
restoreRunProgress(byZone), resetRun. raidCaravan increments the current
zone's count (the dispatcher passes playerSlice.zoneId) and the flat total.
Both the New-Game flow (after the faction picker confirms) and the win/lose
Restart run the same full reset (resetRun + zone/HP/injuries/inventory)
before entering play — Restart reuses the current faction rather than
re-prompting.
A continued save restores conquest progress: restoreRunProgress rebuilds
caravansRaidedByZone from the persisted map (recomputing the flat total so the
two stay consistent); score and kills are not persisted and start fresh. See
save system (save v5 added the per-zone map).
The forest scene stays decoupled from the store and reports events through
callbacks that GameCanvas adapts into dispatches:
- Caravan raided — defeating a caravan fires
onCaravanLooted(drop)(see caravans).GameCanvasdispatchespickUpLootper stack andraidCaravan({ zoneId, lootPoints })(reading the player's current zone), which advances that zone's conquest progress and folds the haul into the loot score. - Soldier killed —
reapDeadSoldiersfiresonEnemyKilledonce per fresh death (see enemy AI / corpses);GameCanvasdispatchesrecordKill.
While playing/paused the HUD shows the objective counter —
Worlds conquered X / N plus the current world's caravan progress
(e.g. Forest 1/3) — and the running Score. Fresh runs from the faction
picker also show the onboarding intro overlay before play
begins, which names the first world and its quota. The win and lose screens reuse
the menu/pause overlay chrome with win- and lose-tinted accents; the win summary
reports how many worlds were conquered.
src/game/objective/objectiveMachine.test.ts— the conquest/win/lose machine:isZoneConquered,unlockedZoneIds, andevaluateOutcome(playing/partial/won/lost, death-priority, and empty-available edges).src/store/gameSlice.test.ts— per-zone raid reducers,restoreRunProgress, and selectors.src/store/appSlice.test.ts—won/losttransitions and guards.src/game/save/schema.test.ts— v4→v5 migration fillscaravansRaidedByZonewith no data loss.src/app/App.test.tsx— HUD objective/score, win and lose screens, and the end-to-end Restart from win → fresh game flow.