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,useStoresfeeding 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¶
- User sees the battlefield with both armies as regiment blocks of individual soldiers, and the entity count against the budget.
- Player selects a regiment and orders it to move to a point on the field.
- Soldiers walk to their slots in the moving block; when blocks touch, soldiers pair off with the nearest enemy and fight.
- User sees soldiers fall, regiment strength and morale drop, and enemy regiments advance on their own.
- Player reinforces, redirects, or pulls back regiments in response.
- 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-schedulephases, 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.