A digital hex-and-counter wargame for the Horse and Musket era (1700–1860), built with SvelteKit and Svelte 5. Derived from Neil Thomas' One-Hour Wargames and Simplicity in Practice systems.
pnpm install # Install dependencies
pnpm dev # Start dev server
pnpm check # Type checking
pnpm test # Run all tests
pnpm format # Auto-format with Prettier
rules/living-rules.md is the authoritative rules document. All implementation should be validated against it.
Each milestone builds on the previous ones. Dependencies are noted where they exist.
unitAt() object-reference comparison bug — now uses coordsEqual() helperMapDeffintion, restGameStore, #clearUnitFLagsMovementAllowance and AttackRange constantsTEST_UNITS placeholder values (hits: 15 → 4)OffsetCoordinates (col/row) for Unit.coordinates — added coordsEqual() to hex.tshex.spec.ts with coordsEqual testsLINE_INFANTRY, LIGHT_INFANTRY, DRAGOONS, LIGHT_HORSE, HORSE, ARTILLERYcore/unitDefinitions.ts — data table with all stats per type (movement, action type, range, hit chance, facing, charge, terrain entry)ActionType enum (MOVE_OR_FIRE, FIRE_AND_MOVE, MOVE_ONLY) and ChargeAbility discriminated unionUnit type: strengthPoints/maxStrengthPoints replace hits; removed movementPoints/inHandToHand; added activatedUnitCounter.svelte with 6 distinguishable NATO-style SVG iconsscenarios.ts; 47 rules-compliance tests in unitDefinitions.spec.tscore/facing.ts with 5 pure functions: getFrontHexsides, getRearHexsides, getZone, rotateFacing, facingStepsBetweengetZone accepts allAround: boolean for Light Infantry (hasFacing: false) and units in Towns — callers compute this flag, keeping facing.ts free of unit-type and terrain importsHexFacing degree values double as clockwise step indices (value / 60 → 0–5), confirmed against honeycomb-grid flat-top cube coordinate deltas — no angular offset between facing and neighbor directionsFacingZone = 'front' | 'rear' to types.tsfacing.spec.ts: all 36 facing × hexside zone combinations, wrap-around rotation, minimum-arc step calculation, allAround overridescore/terrain.ts — TerrainDefinition type and terrainDefinitions data table for all 10 terrain types, plus 4 helper functions: canUnitEnterTerrain, getTerrainCoverModifier, doesTerrainBlockLOS, getTerrainElevationallowedUnitTypes: readonly UnitType[] | null in each definition encodes entry restrictions (null = all units); isImpassable short-circuits canUnitEnterTerrain before allowedUnitTypes is consultedelevation getter to HexCell in hex.ts (HILLTOP → 1, all others → 0); no terrain.ts import needed — logic is inlineterrain.spec.ts (all 60 terrain × unit entry combinations + LOS, cover, elevation, and all definition properties per terrain type); 5 elevation getter tests added to hex.spec.tsPhase enum and advancePhase(); replaced with ActivationStep (AWAITING_ACTIVATION → COMMAND_CHECK → ACTION → CHARGE_RESOLUTION → MORALE_CHECK → ACTIVATION_COMPLETE) and four lifecycle methods on GameStore: activateUnit(id), completeAction(), endActivation(), endPlayerTurn()GameStore state now tracks activationStep and activeUnitId in place of currentPhase; activated flag on each unit is cleared only at game-turn rollover (not at player-turn switch), enforcing once-per-game-turn activationCOMMAND_CHECK, CHARGE_RESOLUTION, and MORALE_CHECK steps are instantaneous auto-pass stubs for M4 — the state machine still enters each step so M7/M9/M10 can slot in real logic without restructuringmoveUnit/changeFacing now gate on activationStep === ACTION and activeUnitId match (not a legacy phase); toggleUnit is a no-op mid-activation and on already-activated unitsGameStore class (alongside the existing initGameStore/getGameStore singleton helpers) so tests can instantiate hermetically with new GameStore(structuredClone(TEST_UNITS), TEST_MAP)+page.svelte replaces the single "Advance Phase" button with four reactively-disabled buttons (Activate Selected / Complete Action / End Activation / End Player Turn) and displays activationStep and activeUnitIdgameStore.spec.ts covering initial state, full activation lifecycle, once-per-turn enforcement, player switch vs game-turn rollover, all guard no-ops, move/facing step gating, and toggleUnit interaction with activationcore/movement.ts with getValidMoveTargets(unit, grid, units, remainingMP?) — BFS pathfinding through front-arc hexsides respecting terrain entry, stacking, Light Infantry pass-through, enemy-adjacency exclusion, and road bonus (+1 on all-road path); remainingMP parameter enables hex-by-hex multi-step movement for 2-MP unitsrequiresDifficultTerrainCheck / rollDifficultTerrainCheck — units with terrainCheckRequired on a difficult terrain hex must pass ~50% roll to leave; failure exhausts all remaining MP without movinghasMoved: boolean on Unit with movementPointsUsed: number and facingStepsUsed: number — enables incremental MP tracking and decouples facing rotation from movement consumptionvalidMoveTargets derived on GameStore recomputes after each move step with remaining MP; highlights valid hexes in the UI via HexTile.svelte yellow stroke overlaymoveUnit validates against validMoveTargets, looks up step cost, increments movementPointsUsed; changeFacing caps total rotation at 1 step if any MP have been spent, 2 steps if stationarymovement.spec.ts (range by type, all 6 facing arcs, all-around overrides, terrain entry, stacking, road bonus, edge cases); 14 new tests in gameStore.spec.ts covering multi-step Dragoon movement, rotate-before-move, 2-step pivot blocking movement, and second-rotation rejectioncore/los.ts with hasLineOfSight(from, to, grid, units) — pure-geometry LOS via cube-coordinate line tracing (lerp + round-to-nearest-hex), no facing-arc dependency (combat module composes that in M7)hexDistance ≤ 1) always have LOS; off-grid endpoints return false; units on the firer/target hex never block their own LOSlos.spec.ts covering trivial cases, open-ground LOS at varied distances, all blocking-terrain types (and the non-blockers Marsh/Lake/River/Bridge/Road/Ford), unit blockers, hill elevation rules, hexside boundary ties, and all plunging-fire variantscore/combat.ts with getValidFireTargets(attacker, grid, units) and resolveFireAction(attacker, target, grid, rng?) returning a transparent FireResult (base, cover, long-range modifiers, final hit chance, hit, damage)los.ts; permissive at hexside boundaries (in-arc if either candidate first-neighbor is a front direction) — the asymmetry vs. LOS's restrictive tie rule is intentional and avoids prompting the user to pick a hexsidefiredThisActivation: boolean on Unit enforces action-type gating: MOVE_OR_FIRE units (Line Inf, Dragoons, Artillery) cannot move/rotate after firing nor fire after moving/rotating; FIRE_AND_MOVE (Light Infantry) may do both in either order; MOVE_ONLY (Cavalry) never fire (encoded as firingRange: 0)GameStore.fireAt(targetId, rng?) validates against validFireTargets, applies clamped SP damage to the target, sets the firer's firedThisActivation, and returns the FireResult; flag is cleared in endActivation and #clearActivatedFlags (turn rollover)UnitCounter.svelte gains a fireTarget prop that draws a non-rotating red ring around eligible targets; +page.svelte computes a fire-target id set from validFireTargets and branches the click handler to store.fireAt(unit.id) when the target is eligiblecombat.spec.ts (range, arc/all-around/Town, LOS, fired flag, multi-target, hexside-tie permissive, base hit chances, cover, long-range, RNG-driven hit/double-damage/miss, second-draw frugality, clamping); 14 new tests in gameStore.spec.ts covering hit/double/miss SP arithmetic, flag set on hit and miss, target/step gating, MOVE_OR_FIRE vs FIRE_AND_MOVE mutual exclusion, SP-clamp at 0, and endActivation clearing the flagImplement charge action and opposed resolution per rules §6.3.
Add to core/combat.ts:
canCharge(unit, target) → boolean (checks mayCharge, restrictions, terrain)resolveCharge(attacker, defender, grid) → ChargeResultDepends on: M2 (flank/rear detection), M3 (terrain), M5 (charge as movement)
Files: core/combat.ts, gameStore.svelte.ts
Tests: Opposed contest with controlled RNG. All modifier combos. Retreat direction. Charge restriction enforcement.
Implement morale checks triggered when a unit takes hits, per rules §9.
New core/morale.ts:
checkMorale(unit, modifiers) → MoraleResult (pass/fail)New core/retreat.ts:
getRetreatHex(unit, attackSource, grid, units) → best rear hex or nullDepends on: M7/M8 (combat triggers morale), M2 (rear hexsides for retreat)
Files: new core/morale.ts, new core/retreat.ts, gameStore.svelte.ts
Tests: Morale at various SP ratios. All modifier combos. Retreat selection. Blocked retreat penalty. No cascade.
Implement leaders, command radius, and command checks per rules §8.
New core/command.ts:
Leader type: id, attachedToUnitId, commandRadius (default 2)isInCommand(unit, leaders, grid) → boolean (within any friendly leader's radius)resolveCommandCheck(unit, leaders) → boolean (50% base, −15% if far out of range)Leader allocation: 1 per 2 units (rounded down, minimum 1), assigned at scenario setup.
Depends on: M4 (COMMAND_CHECK activation step)
Files: new core/command.ts, types.ts (Leader type), gameStore.svelte.ts, scenarios.ts
Tests: Command radius. In/out of command. Check resolution. Casualty and replacement. Degrading radius.
Formalize unit removal and retreat finalization.
Depends on: M9 (retreat system), M10 (leader attachment)
Files: core/combat.ts or new core/elimination.ts, gameStore.svelte.ts
Tests: Elimination at 0 SP. Leader removal distinction. Victory check trigger.
Replace hardcoded test data with a scenario system.
Scenario type:
New core/victory.ts:
checkVictoryConditions(scenario, gameState) → result or nullGameStore init takes a Scenario instead of raw units + map.
Files: scenarios.ts (major expansion), new core/victory.ts, gameStore.svelte.ts, maps.ts (absorbed into scenarios)
Tests: Scenario loading. Victory condition evaluation.
Minimal but functional interface for all game mechanics. Build only after core systems work.
Files: +page.svelte, HexTile.svelte, UnitCounter.svelte, new components as needed
M0 Bug Fixes
│
M1 Unit Types
│
├── M2 Facing & Zones
│ │
├── M3 Terrain System
│ │
└── M4 Turn & Activation ──┐
│ │
M5 Movement ───────────┘ (needs M2, M3, M4)
│
M6 Line of Sight (needs M3)
│
M7 Firing Combat (needs M2, M6)
│
M8 Charge Combat (needs M2, M3, M5)
│
M9 Morale (needs M7/M8, M2)
│
M10 Command & Control (needs M4)
│
M11 Elimination & Retreat (needs M9, M10)
│
M12 Scenarios & Victory
│
M13 UI Polish
core/ as pure functions — no Svelte imports, no side effects. Accept an RNG parameter where randomness is needed..map(), not mutating in place.core/ module gets a companion .spec.ts with server-side tests before wiring into the store or UI.