Skip to content

demo-totalwar: Product Brief

Status: draft (W-018, awaiting user approval).

Target app: apps/demo-totalwar. Refs: concept §8, concept §4 (koota limits), ecs-schedule spec.

1. Vision

Player commands a regiment-based army in a Total War style field battle: every regiment is made of individually simulated soldiers (one entity per soldier), and the player orders regiments to march, engage, and break the enemy while thousands of soldiers fight on screen. The battle size can be pushed up to the most entities koota can hold in one world, and the demo shows honestly where that stops being playable.

2. Audience and purpose

  • Audience: developers learning ECS / flecs concepts on koota, and anyone who wants to see how far koota scales. The demo must still be a playable 5-10 minute battle on its own.
  • Teaches: data-oriented design at scale (SoA traits, cached queries, updateEach, useStores feeding R3F instanced meshes), an ordered per-frame system pipeline (ecs-schedule), regiments as entities that own soldiers, koota's hard limits (entity ID budget, world cap), and measured performance instead of claims.

3. Core loop

  1. User sees the battlefield with both armies as regiment blocks of individual soldiers, and the entity count against the budget.
  2. Player selects a regiment and orders it to move to a point on the field.
  3. Soldiers walk to their slots in the moving block; when blocks touch, soldiers pair off with the nearest enemy and fight.
  4. User sees soldiers fall, regiment strength and morale drop, and enemy regiments advance on their own.
  5. Player reinforces, redirects, or pulls back regiments in response.
  6. Repeat until one army has no fighting regiments left (victory or defeat), then restart.

4. Entity budget (verified in reference/koota)

Limit Value Source Effect on the demo
Entity ID bits 20 (ENTITY_ID_BITS), so IDs 0 to 1,048,575 = 1,048,576 IDs per world packages/core/src/entity/utils/pack-entity.ts Hard cap on entities alive at once. Destroyed IDs are recycled, so the cap is on the peak, not on total spawns.
No cap check allocateEntity takes the next ID (maxId++) when none is free to recycle and never compares it to the limit packages/core/src/entity/utils/entity-index.ts Entity number 1,048,577 would be packed with its ID masked to 20 bits (wrapping to 0) while the index keeps counting. The demo must refuse to spawn before that point.
World entity 1 entity per world (worldEntity, holds world-level traits) packages/core/src/world/world.ts Counts against the cap.
Generation bits 8 (GENERATION_BITS) pack-entity.ts A stale handle can match again after 256 reuses of a slot. The demo must not keep handles to dead soldiers (e.g. a "target" handle must be cleared when its target dies).
World ID bits 4 (WORLD_ID_BITS), at most 16 worlds at once; allocateWorldId throws beyond that pack-entity.ts, packages/core/src/world/utils/world-index.ts One battle world. Restart destroys the world and creates a fresh one. Other tools that create scratch worlds (see ecs-serialize open question 6) count against the 16.

Derived soldier target (budget of 1,048,576 entity IDs):

Use Entities
Soldiers (max tier) 1,000,000
Regiments (2,000 at 500 soldiers each) 2,000
World entity 1
Reserved for later (projectiles, effects, markers) 40,000
Spare headroom 6,575
Total 1,048,576
  • Maximum soldier target: 1,000,000 soldiers. This is the stress tier: it must never exceed the ID cap, and it reports honest tick and frame numbers. It is not promised to be playable at 60 FPS.
  • Formations are data on the regiment and on each soldier (slot offset), not entities, to keep the budget for soldiers.
  • Guard: the demo refuses to spawn any entity once 1,042,001 are alive (the cap minus the 6,575 spare). Battle setup stays at or below 1,000,000 soldiers and 2,000 regiments, and the reserved 40,000 are for later runtime spawns. The user sees the budget and a clear "entity budget reached" message instead of a corrupted world.

5. ECS showcase

flecs concept Shown by Package
Entities, components, tags Soldiers with Position, Velocity, Health, Facing; regiments with Strength, Morale, Order; Selected / Routing / Dead tags (TW-F01, TW-F03) koota
SoA traits at scale Position and velocity as SoA traits so a million soldiers update without per-entity objects (TW-F01, TW-F06) koota
Cached queries and updateEach Per-tick movement, engagement, and damage systems iterate soldiers with updateEach (TW-F02, TW-F03) koota
useStores / R3F instanced meshes One instanced mesh per army reads soldier stores directly; no React component per soldier (TW-F01) koota (koota/react)
Relations and target filters MemberOf(regiment) is the default: a soldier is in exactly one regiment; "all soldiers of this regiment" for strength and block layout (TW-F02, TW-F03). Risk: relation cost at 1M soldiers is unmeasured; if the performance spike shows it is too slow, soldiers use a plain regiment ID field instead and this row is dropped (see notes). koota
Pipeline phases, ordered systems Per-frame pipeline: orders -> formation -> movement -> engagement -> damage -> morale -> cleanup, driven from R3F useFrame (TW-F02 to TW-F05) ecs-schedule
Added / Removed / Changed Casualty counters and instance cleanup react to soldiers dying or regiments changing state (TW-F03, TW-F05) koota
Observers Battle log lines on a regiment routing or being destroyed (TW-F05) koota
Prefabs Regiment types (Spearmen, Swordsmen) spawned from templates in content data (TW-F12) ecs-prefab
Serialization Save and resume a battle (TW-F09) ecs-serialize
Explorer-style inspection Aggregate panel (counts, trait totals, selected regiment), not a list of every entity (TW-F10) ecs-devtools
Spatial lookup Uniform spatial grid kept in the demo, rebuilt by a system each tick, so "nearest enemy" does not scan all soldiers (TW-F18) koota
Data-driven formations Formations are data (slot offsets) and slot assignment is a system, so switching formation at runtime only changes data (TW-F07) koota, ecs-schedule
Honest limits Entity ID cap and guard, 16-world cap, no workers, Vitest bench of the soldier-count tiers (TW-F06, TW-F11) koota

ecs-relations is not driven by this demo.

6. Backlog

ID Feature Milestone Work
TW-F01 User sees a flat battlefield with two armies as regiment blocks of individual soldiers drawn as instanced meshes, can pan, orbit, and zoom the camera, and sees the soldier count and entity budget M1
TW-F02 Player can select a regiment and order it to move to a point; its soldiers walk to their places in the block and the block turns to face the destination M1
TW-F03 User sees soldiers of touching regiments pick the nearest enemy and fight; each hit removes health and a dead soldier disappears, so regiment strength drops visibly M1
TW-F04 User sees enemy regiments advance on and attack the nearest player regiment on their own M1
TW-F05 User sees regiment morale fall with casualties until the regiment routs and its soldiers flee; the battle ends in victory or defeat when one army has no fighting regiments, with a battle log, and the player can restart M1
TW-F06 Player can pick a battle size from presets (small, 20,000, 100,000, maximum 1,000,000 soldiers), sees the entity count against the 1,048,576 limit, and the demo refuses to spawn beyond the budget guard; HUD shows tick time and FPS M1
TW-F07 Player can switch a regiment's formation (line, column, wedge) while the battle runs and drag its facing; soldiers walk to their new slots, showing that formations are data and slot assignment is a system M2
TW-F08 Player can field ranged regiments that shoot projectiles (projectiles are entities from the reserved budget) M2
TW-F09 Player can save a battle and resume it after reload M2
TW-F10 User can open a devtools panel with entity counts, trait totals, and the selected regiment's traits M2
TW-F11 Developer can run a Vitest bench that measures the soldier movement step and one full pipeline pass at each soldier-count tier (1,000, 20,000, 100,000, 1,000,000) and prints the results M2
TW-F12 Player can pick regiment types (Spearmen, Swordsmen, Cavalry) from content data, built from prefab templates M2
TW-F13 Player can pause, change speed, and control the whole battle from the keyboard with on-screen key hints M2
TW-F14 Player can pick the scenario (armies, battlefield, size) from a list in content data M2
TW-F15 Player can fight over terrain with hills, forests, and line of sight later
TW-F16 Player can fight on a campaign map with armies that carry over between battles later
TW-F17 Fixed-step simulation with replay, once ecs-schedule has fixed-step support later
TW-F18 User sees soldiers find their nearest enemy without slowdown as the army grows, because the demo keeps a spatial grid of soldiers (needed by TW-F03 and TW-F04) M1

M1 order: TW-F01, TW-F02, TW-F18, TW-F03, TW-F04, TW-F05, TW-F06.

Notes: - Every M1 action (select, move, restart, pick battle size) must already be keyboard-reachable (demo rule); TW-F13 adds pause, speed, hints, and polish. - Dependency/risk (scale): a million soldiers in R3F instanced meshes and a per-frame JS simulation are unlikely to hold 60 FPS. M1 playability targets the smaller presets (section 7); the larger presets exist to measure. The performance spike measures tick time and memory at 20,000, 100,000, and 1,000,000 and confirms or changes the FPS tiers before the specs fix them. - Dependency/risk (nearest enemy): pairing each soldier with the nearest enemy at 1M soldiers needs a spatial structure; brute force is not an option. The demo builds a uniform grid itself (TW-F18, M1). The performance spike also measures its rebuild and query cost. How it is built belongs in the spec. - Dependency/risk (MemberOf): regiment membership uses the MemberOf(regiment) relation by default. Koota relation scaling (concept §4: some non-exclusive relation operations are linear) is unmeasured at 1M. If the performance spike shows it is too slow, membership falls back to a plain regiment ID field on each soldier and the relation showcase row is dropped. - Decision (timing): ecs-schedule v0 has no fixed step or intervals (concept §8). The demo clamps dt itself and runs the pipeline per frame; TW-F17 waits for fixed-step support in ecs-schedule. - Decision (benchmarks): benchmarks use Vitest bench only (TW-F11). There is no bitECS dependency and no cross-library comparison. - Decision (M2 scope): ranged units (TW-F08) and formations other than one block, including runtime formation change (TW-F07), are M2. - Dependency/risk (TW-F09, TW-F10): ecs-serialize snapshots and the ecs-devtools entity list are not designed for 1,000,000 entities. Both are M2 and must be limited to the small presets or aggregate views until measured. - Dependency/risk (M1 packages): M1 depends only on koota and ecs-schedule (frame-driven). ecs-prefab, ecs-serialize, and ecs-devtools are M2.

7. Non-functional targets

  • Entity budget: never exceed 1,048,576 entity IDs alive in the battle world; guard stops spawning before that (section 4); at most one battle world at a time.
  • Performance (proposals, to be confirmed by the performance spike):
  • Small preset (about 1,000 soldiers): 60 FPS, one pipeline pass in well under one frame.
  • 20,000 soldiers: 60 FPS (the M1 playable target).
  • 100,000 soldiers: simulation runs; FPS is measured and shown, with a target of at least 30.
  • 1,000,000 soldiers (maximum): spawns without exceeding the cap and the simulation runs; FPS is measured only, with no target.
  • Accessibility: all actions keyboard-reachable; WCAG AA contrast; regiment names, strength, morale, and state (Moving, Fighting, Routing) shown as text, not color or shape alone; the army and regiment list is readable as a text list.
  • Persistence: M1 none. M2 saves the battle (regiments, soldiers for the small presets, orders, morale, clock) via ecs-serialize.

8. Path to a real app

  • Regiment types, stats, formations, scenarios, and battle sizes live in src/content/ and can be swapped for other data.
  • RNG (hit rolls, morale checks) behind one seam, so a battle can be seeded and replayed.
  • Per-frame pipeline as ecs-schedule phases, so new phases (ranged fire, terrain, abilities) slot in and a fixed-step driver can replace the frame driver later.
  • Enemy AI behind one decision function per regiment (M1: advance on the nearest player regiment), replaceable by richer tactics.
  • Spatial lookup behind one function ("nearest enemy to this soldier"), replaceable by a better structure.
  • World state serializable, leaving room for replays and networked play.

9. Out of scope

  • Terrain, line of sight, weather, and siege (see TW-F15).
  • A campaign map and carry-over between battles (see TW-F16).
  • Ranged units, cavalry charges, and formations other than one block in M1 (these are M2: TW-F07, TW-F08, TW-F12).
  • Individual soldier AI beyond "walk to slot, fight nearest enemy".
  • Web workers and multithreading (concept §1: dropped).
  • More than one world per battle, and worlds beyond koota's cap of 16.
  • Multiplayer and networking.