Skip to content

Status: approved

demo-dnd

Summary

apps/demo-dnd is a playable, turn-based, D&D-style skirmish on a grid: the player leads a fixed party (Fighter, Rogue, Wizard) against 3–4 template monsters, moving and acting with each hero while the initiative order, d20 rolls (advantage, crits), and Poisoned play out in a 2–5 minute fight (brief). This spec covers the six M1 features DND-F01, DND-F02, DND-F03, DND-F04, DND-F05, and DND-F10. It builds on three package specs that are already approved: the turn pipeline runs on ecs-schedule as a detached, manual phase chain stepped once per turn, monster templates are ecs-prefab prefabs, and the live inspector is ecs-devtools. The demo adds no engine feature. The rules are deliberately small: d20 + bonus vs. armor class, advantage, crits, and one status effect (Poisoned). Persistence (DND-F08), keyboard hints (DND-F07), template variants (DND-F09), and content selection (DND-F11) are M2 and out of scope here.

References

  • Brief: docs/products/demo-dnd.md §3 (core loop), §4 (ECS showcase), §5 (backlog), §6 (targets), §7 (seams), §8 (out of scope). concept §8. demo-rules (layout, actions, keyboard, content data).
  • ecs-schedule: SCH-100 (definePhase), SCH-104 (detached phases are manual), SCH-107..109 (run skips manual phases, runPhase/runPhases step them), SCH-042 (dt = 0), SCH-013 (system ordering), SCH-126, SCH-127, SCH-132 (sets and set conditions), SCH-118 (runIf), SCH-143 (systemsOf), SCH-060, SCH-065 (per-world state, world.reset()), SCH-140 (schedule name). The dnd/turn example in its API sketch is the driver pattern used here.
  • ecs-prefab: PREFAB-001 (createPrefab), PREFAB-003 and PREFAB-005 (templates never match queries or tracking), PREFAB-010..012 (instantiate, copy, IsA), PREFAB-014 and PREFAB-071 (independent copies), PREFAB-030 and PREFAB-032 (overrides), PREFAB-066 (handles dead after world.reset()), PREFAB-070 and PREFAB-073 (demo scenario), PREFAB-008 (world.onAdd fires for prefabs).
  • ecs-devtools: DEVTOOLS-001..005 (names and labels), DEVTOOLS-014 (pairs), DEVTOOLS-033, the WorldInspectorProps sketch and the React panel text "owns one store per mount" (store lifetime, no refresh prop), DEVTOOLS-056 (panel keyboard), DEVTOOLS-057 (excluded toggle), DEVTOOLS-058 (live), DEVTOOLS-062, DEVTOOLS-064 (manual layout and FPS), DEVTOOLS-065 (host owns open/close), DEVTOOLS-072 (no interference), DEVTOOLS-080..083 (scenario), L9 (reset drops observers).
  • koota (public API, read via the koota skill and reference/koota):
  • trait, relation({ store }), createWorld, world.reset(), world traits through world.add, world.get, world.set.
  • Pair data: entity.add(HasEffect(target), data), entity.get(HasEffect(target)), entity.set(HasEffect(target), data), entity.remove(HasEffect(target)), entity.targetsFor(HasEffect).
  • Queries: world.query, Not, createAdded, createRemoved (modifiers accept relations, see .github/skills/koota/references/queries.md).
  • Observers: world.onAdd, world.onRemove, world.onQueryAdd, world.onQueryRemove. world.reset() drops all observers (devtools L9), so setup registers them again.
  • Actions and React: createActions, useActions, useQuery, useQueryFirst, useTrait, WorldProvider. useQuery is tested by koota to survive world.reset() (reference/koota/packages/react/tests/query.test.tsx).
  • flecs: PrefabsManual.md (templates), Relationships.md (pairs with data), ObserversManual.md (observers), Systems.md § Pipelines (phases).

Requirements

All requirements are [test] or [manual] as tagged, and [ecs] (model and system behaviour, headless Vitest, fresh koota world per test) or [ux] (what the user sees or does, Testing Library with a real world in WorldProvider, or a manual check). Package behaviour is referenced by ID and never restated. Groups map to work items: F01 M (W-019), F02 M (W-020), F03 M (W-021), F04 S (W-022), F05 M (W-023), F10 S (W-024). IDs are numbered per group (F01 001–019, F02 020–039, F03 040–059, F04 060–079, F05 080–099, F10 100–119). Retired IDs are struck through and never reused.

Terms:

  • A combatant is an entity with Name, Health, and exactly one of Hero or Monster. Monster templates and the Poisoned effect entity (DND-081) are not combatants. A combatant is living if it lacks Defeated. Opposing means Hero vs. Monster.
  • distance(a, b) is |ax − bx| + |ay − by|, so adjacent means distance 1 (no diagonals, OQ3).
  • The active combatant is the one with the Active tag. A hero turn or monster turn is a turn whose active combatant has that tag.
  • Status is Encounter.status: 'ongoing' | 'victory' | 'defeat'.
  • Content values (stats, positions, durations, names) come from src/content/ (DND-006). The numbers in the Content table are starting values, not requirements.

DND-F01 Grid, party, monsters, movement

F01 introduces the Encounter world trait (movesLeft, acted) and the Active tag. F02 adds the pipeline that sets them. F01 tests set them directly.

  • DND-001 [ecs][test]: setupEncounter(world, content?) MUST spawn one hero for each entry of content.party (default: Fighter, Rogue, Wizard). Each hero MUST have Hero, Name, Health (hp and max both the content hp), ArmorClass, Speed, Attack, and Position, with every value from content (DND-006). Content-defined abilities (e.g. PoisonStrike, DND-080) are added when the entry has them.
  • DND-002 [ecs][test]: Setup MUST create one template per distinct monster kind with createPrefab (PREFAB-001), carrying Monster and the kind's stats. It MUST then create one monster per entry of content.encounter.monsters with instantiate (PREFAB-010, PREFAB-011), overriding Name (unique per monster, e.g. "Goblin 2") and Position (PREFAB-030, PREFAB-032). The default content has 3 or 4 monsters.
  • DND-003 [ecs][test]: Templates MUST never appear in any demo query, observer, or view. world.query(Health) MUST return exactly the heroes and the monster instances (PREFAB-003, PREFAB-070), and the demo's observers MUST use query-based observers (PREFAB-005, PREFAB-073).
  • DND-004 [ecs][test]: Monster instances MUST be independent. Damaging one Goblin MUST NOT change another Goblin's or the template's Health (PREFAB-014, PREFAB-071). Each instance MUST have IsA(template) (PREFAB-012).
  • DND-005 [ecs][test]: After setup, every combatant's Position MUST be inside the grid (0 ≤ x < width, 0 ≤ y < height) and no two combatants MUST share a cell. The grid MUST be at most 12×12. setupEncounter MUST throw, naming setupEncounter and the offending content entry, if content violates this.
  • DND-006 [ecs][test]: Stats, names, positions, and grid size MUST come only from content. A test MUST pass custom content (different numbers and names) to setupEncounter and assert that the spawned values equal it. Systems and actions MUST NOT contain stat literals.
  • DND-010 [ecs][test]: moveActive(world, dir), with dir one of 'up' | 'down' | 'left' | 'right', MUST move the active hero one cell and return true iff all hold: status is 'ongoing', the active combatant is a hero, Encounter.acted is false, Encounter.movesLeft ≥ 1, and the target cell is inside the grid and not occupied by a living combatant. It MUST decrement movesLeft by 1 and write Position with set.
  • DND-011 [ecs][test]: In every other case moveActive MUST return false and change nothing. One test each: out of bounds, cell occupied by a hero, cell occupied by a monster, movesLeft 0, already acted, no Active, the active combatant is a monster, status not 'ongoing'.
  • DND-012 [ecs][test]: A hero with Speed s and free cells MUST be able to make exactly s successful moves in a turn (in any directions, including back to a cell it left), and the (s+1)th MUST return false.
  • DND-013 [ecs][test]: A Defeated combatant MUST NOT occupy a cell: a hero can move onto the cell of a defeated combatant (DND-031).
  • DND-015 [ux][test]: The grid MUST be a labelled region ("Grid") of width × height cells. Each cell MUST have a text label with its coordinates and its occupant: "3,2 Goblin 2 (monster)", "1,1 Fighter (hero)", "4,4 empty". Defeated combatants are not shown. Side and occupancy MUST NOT depend on colour alone (brief §6).
  • DND-016 [ux][test]: The game area MUST be a focusable container (tabIndex=0, label "Game") that holds the grid and the action controls. The arrow keys on a focused game area MUST call moveActive and preventDefault (no page scroll). Key rule (the only one, DND-055 and DND-091 refer to it): game keys MUST be handled by a keydown handler on the game area, not on window, and only when event.target is the game area element itself. A key pressed while focus is on a button inside the game area (DND-056), in the devtools panel (DEVTOOLS-056, DND-107), or in any input MUST NOT run any game-key handler and MUST NOT be preventDefaulted by the game, so the native behaviour of buttons (Enter and Space activate the button, which then runs that button's own action) and of Tab (normal focus order) is kept. The handler MUST ignore event.repeat events: a held key calls no action, and a repeat of a key that is preventDefaulted on its first press (arrows, and Tab while targeting, DND-055) is still preventDefaulted, so a held arrow does not scroll the page. The game area MUST have focus after mount and after restart (DND-037).
  • DND-017 [ux][test]: During a hero turn the UI MUST show the active hero's name and the text "Moves left: N" (N = Encounter.movesLeft), updating after every move.
  • DND-018 [ux][test]: Clicking a cell orthogonally adjacent to the active hero MUST be equivalent to the matching moveActive call. Clicking any other cell MUST do nothing, unless targeting is active (DND-058). Grid cells are not tab stops: the keyboard path is DND-016.
  • DND-019 [ux][manual]: A grid of up to 12×12 MUST fit a 1024×768 viewport without scrolling, with cell labels, occupants, and the party legible and WCAG AA contrast in both themes. Verify with the Acceptance checklist (A1).

DND-F02 Initiative, turns, victory/defeat, restart

  • DND-020 [ecs][test]: Setup MUST give every combatant Initiative { value } = one d20 (DND-033) + the content initiativeBonus. Each monster instance MUST get its own roll as an override at instantiate (PREFAB-030), so two Goblins can differ and the template is unchanged (PREFAB-014).
  • DND-021 [ecs][test]: initiativeOrder(world) MUST return the living combatants (never templates) sorted by Initiative.value descending, ties by entity.id() ascending. The order never relies on query iteration order (ecs-schedule "Notes for system authors").
  • DND-022 [ecs][test]: Setup MUST add the Encounter world trait with round: 1 and status: 'ongoing', put Active on the first entry of initiativeOrder, and then run advance (DND-028), so after setup either a hero is active or the encounter is over.
  • DND-023 [ecs][test]: While status is 'ongoing' exactly one combatant MUST have Active. When status is not 'ongoing' none MUST have it.
  • DND-024 [ecs][test]: The turn pipeline MUST be the schedule dnd/turn (SCH-140) with the custom phases StartOfTurn → Move → Act → Resolve → EndOfTurn, each after the previous one through dependsOn and with no edge to a builtin phase, so the chain is detached and manual (SCH-100, SCH-104). schedule.run(world, 0) MUST run no demo system (SCH-107). Phases are stepped only with runPhase/runPhases and dt = 0 (SCH-108, SCH-109, SCH-042).
  • DND-025 [ecs][test]: schedule.systemsOf(phase) (SCH-143) MUST return these systems in this order: StartOfTurn: turn/begin. Move: ai/move. Act: ai/decide, combat/act. Resolve: effects/tick, combat/defeat, encounter/outcome. EndOfTurn: turn/cues, turn/end. Order within a phase comes from after edges (SCH-013).
  • DND-026 [ecs][test]: ai/move and ai/decide MUST be members of the set dnd/monster-turn (SCH-126, SCH-127), whose runIf is true iff status is 'ongoing' and the active combatant is a monster with Health.hp > 0 (SCH-132, SCH-118). With a hero active, runPhases(world, Move, Act, 0) MUST leave all positions, healths, and the log unchanged.
  • DND-027 [ecs][test]: turn/begin (StartOfTurn) MUST set Encounter.movesLeft to the active combatant's Speed, Encounter.acted to false, and clear Intent and Targeting (DND-054).
  • DND-028 [ecs][test]: advance(world) MUST loop while status is 'ongoing': run StartOfTurn; if the active combatant is a monster, run runPhases(world, Move, EndOfTurn, 0) and continue; if it is a hero, stop and wait for input. It MUST terminate with either a living hero active, or status not 'ongoing' and no Active. Consecutive monster turns MUST resolve synchronously in initiative order within one call (OQ6). advance MUST NOT clear Cues: each turn/cues run adds to them (DND-035).
  • DND-029 [ecs][test]: A hero turn MUST end when the hero acts (attack DND-042, usePoison DND-082) or calls endTurn(world): Act through EndOfTurn run via runPhases(world, Act, EndOfTurn, 0), then advance. After acting, the hero cannot move or act again that turn (OQ5). endTurn consumes no dice and logs nothing. Both return false and change nothing unless status is 'ongoing' and the active combatant is a hero. Every hero action that returns true (moveActive, attack, usePoison, endTurn) MUST clear Cues first, before it changes anything else (DND-035); an action that returns false leaves Cues untouched.
  • DND-030 [ecs][test]: turn/end (EndOfTurn) MUST move Active to the next living combatant after the current one in the sort order of DND-021 including defeated combatants (so a combatant defeated during its own turn is skipped over), wrapping around and incrementing Encounter.round when it passes the end. Example: A, B, C with B defeated: after A comes C; after C comes A and round + 1. When status is not 'ongoing' it MUST only remove Active.
  • DND-031 [ecs][test]: combat/defeat (Resolve) MUST add Defeated to every combatant with Health.hp 0 that lacks it. A defeated combatant MUST NOT appear in initiativeOrder, MUST NOT occupy a cell (DND-013), MUST NOT be a valid target (DND-040), and MUST NOT get a turn (DND-030).
  • DND-032 [ecs][test]: encounter/outcome (Resolve, after combat/defeat) MUST set status 'victory' when every monster is Defeated, and 'defeat' when every hero is Defeated. When the status is not 'ongoing', advance MUST stop, and every hero action (moveActive, attack, usePoison, endTurn) MUST return false.
  • DND-033 [ecs][test]: All randomness MUST go through the world trait Rng (an AoS trait holding { next(): number } in [0, 1)) and rollDie(world, sides) = 1 + floor(next() * sides). createSeededRng(seed) MUST give a deterministic sequence. setupEncounter(world, content?, { seed? }) MUST install a seeded Rng when given a seed, and tests MUST be able to install a scripted Rng. Two worlds set up with the same seed and given the same sequence of hero inputs MUST end with the same combatant state and the same log, and so MUST one world before and after restartEncounter (DND-034).
  • DND-034 [ecs][test]: restartEncounter(world) MUST call world.reset() and then setupEncounter with the same content and the same seed as the original setupEncounter call (DND-033), so a restart replays the same fight for the same hero inputs. setupEncounter MUST remember that content and seed for the world outside the ECS traits (e.g. a WeakMap keyed by world in actions.ts), because world.reset() removes every trait. The Rng is installed again freshly from the seed (its sequence starts over); if setup got no seed, the restart is unseeded as well. A scripted Rng that a test installed by hand is not kept. Afterwards: all old entity handles are dead (PREFAB-066), round is 1 and status 'ongoing', CombatLog is empty, templates and the Poisoned effect entity (DND-081) are new, the schedule state is back to defaults (SCH-065), and the observers of DND-052 and DND-089 are registered again (one entry per event, not two, not zero). A test MUST play a seeded scripted fight, restart, replay the same hero inputs, and assert the same combatant state and the same log kind and text sequence as in the first run.
  • DND-035 [ecs][test]: turn/cues (EndOfTurn, before turn/end) MUST write the world trait Cues with defeated, effectsAdded, and effectsRemoved (arrays of entities) from Added(Defeated), Added(HasEffect), and Removed(HasEffect) queries (createAdded/createRemoved instances owned by the system). One action call (a hero action and all the monster turns advance runs after it, DND-028) runs turn/cues once per turn, and each run MUST append its changes to the arrays already in Cues (world.set with new arrays, in order, no duplicates). Cues MUST be cleared (all three arrays emptied) only at the start of the next hero action (DND-029) and by setup and restart. So after any action call Cues hold every change since the last hero input, not only the last turn's. Test: a hero attack defeats a monster and two monster turns follow in the same call; after the call Cues.defeated still contains that monster, and after the next accepted hero action it does not.
  • DND-036 [ux][test]: The initiative panel MUST be a labelled region ("Initiative") with one item per entry of initiativeOrder, in order, showing the name, the initiative value, the side as text (hero or monster), and hp/max. It MUST show "Round N". The active item MUST have aria-current="true" and the text "Active". Defeated combatants MUST appear in a separate list "Fallen". An entity in Cues.defeated MUST show the text "(new)" next to its name, so every combatant defeated since the last hero input (by the hero action or by the monster turns after it) shows it until the next accepted hero action.
  • DND-037 [ux][test]: When status is not 'ongoing' the UI MUST show a role="status" banner with the text "Victory" or "Defeat" and a "Restart" button. Activating it (or pressing R on the game area) MUST call restartEncounter and move focus to the game area. After restart the UI MUST show the new encounter (round 1, new party, empty log) with no stale entities. When the status becomes not 'ongoing' while an action button ("Attack", "Poison Strike", "End turn") has focus, so that it becomes disabled (DND-038, DND-056, DND-091), focus MUST move to the game area, so the keyboard user does not lose focus to body and can press R.
  • DND-038 [ux][test]: An "End turn" button (key E on the game area) MUST call endTurn. It MUST be disabled (and aria-disabled) when endTurn would return false.
  • DND-039 [ux][manual]: A full round, including every monster turn, MUST feel instant and leave the user able to follow what happened. Logic for a round of at most 50 combatants on 12×12 MUST take under 16 ms (brief §6). Verify with the Acceptance checklist (A2).

DND-F03 Attacks, advantage, crits, damage, combat log

  • DND-040 [ecs][test]: Attack MUST have bonus, diceCount, diceSides, damageBonus, and range. A target is valid for an attacker with range r iff both are living combatants, they are opposing, and 1 ≤ distance ≤ r. Melee combatants have range 1 (adjacent). The Wizard has a larger content range (starting value 4). There is no line of sight (brief §8).
  • DND-041 [ecs][test]: validTargets(world, attacker, range) MUST return the valid targets sorted by distance ascending, then entity.id() ascending. It MUST NOT include defeated combatants, the attacker's own side, or templates.
  • DND-042 [ecs][test]: attack(world, target) MUST, when status is 'ongoing', the active combatant is a hero that has not acted, and target is valid for its Attack.range, set Intent { kind: 'attack', target } and Encounter.acted = true, then end the turn as in DND-029 and return true. Otherwise it MUST return false, consume no dice, and add no log entry.
  • DND-043 [ecs][test]: combat/act (Act) MUST resolve an Intent of kind 'attack' for the active combatant, hero or monster, under DND-044..049, and then clear Intent. An intent whose target is no longer valid MUST be dropped without dice.
  • DND-044 [ecs][test]: An attack roll MUST roll one d20 through rollDie (DND-033), or two when the roll has advantage (DND-045), and keep the higher. total = kept die + Attack.bonus. The attack hits iff kept = 20, or kept ≠ 1 and total ≥ ArmorClass.value of the target.
  • DND-045 [ecs][test]: Poisoned advantage rule (the only definition). An attack roll MUST have advantage iff the target has HasEffect(Poisoned) at the moment the roll is made, whoever the attacker is. M1 has no other source of advantage and no disadvantage. Every other requirement that mentions advantage refers to this ID.
  • DND-046 [ecs][test]: Critical hit: a kept die of 20 MUST hit, roll the damage dice twice (2 × diceCount dice of diceSides), and add damageBonus once.
  • DND-047 [ecs][test]: Natural 1: a kept die of 1 MUST miss even when total ≥ ArmorClass.value. With advantage the kept die is the higher, so only two 1s miss that way.
  • DND-048 [ecs][test]: Damage on a hit is the sum of the damage dice plus damageBonus. Health.hp MUST become max(0, hp − damage), written with set, and Health.max MUST NOT change. A miss MUST NOT change Health. Adding Defeated at 0 hp is combat/defeat's job in the same turn (DND-031), not combat/act's.
  • DND-049 [ecs][test]: Dice MUST be consumed in this order: the d20(s), then the damage dice only on a hit. A scripted Rng test asserts the exact consumption for hit, miss, crit, advantage, and nat-1 cases, and that an invalid attack (DND-042) consumes none.
  • DND-050 [ecs][test]: The combat log MUST be the world trait CombatLog with entries, each { id, round, kind, text, data }, ids strictly increasing, in chronological order. Entries MUST be appended with world.set and a new array so useTrait re-renders. kind is one of 'attack', 'move', 'ability', 'effect', 'poison', 'defeat', 'outcome'.
  • DND-051 [ecs][test]: Each attack MUST append one 'attack' entry whose data has { attacker, target, rolls, kept, bonus, total, ac, advantage, outcome, damage } (outcome is 'hit' | 'miss' | 'crit'; rolls has every d20 rolled). Its text MUST contain both names, every d20 value, the bonus, the total, the AC, the word "advantage" iff advantage is true (DND-045), the outcome as a word (hit, miss, or critical hit), and the damage number on a hit. Nothing is conveyed by colour alone.
  • DND-052 [ecs][test]: A query-based observer registered by setup (world.onQueryAdd on Defeated, never world.onAdd, PREFAB-005, PREFAB-008) MUST append one 'defeat' entry "X is defeated" per combatant that becomes Defeated, and none for templates (PREFAB-073).
  • DND-053 [ecs][test]: Each setup MUST register its observers exactly once, and a restart (DND-034) MUST NOT leave observers of the old encounter behind or lose them (devtools L9): a defeat after a restart logs exactly one entry.
  • DND-054 [ecs][test]: Targeting MUST be the world trait Targeting { mode: 'none' | 'attack' | 'poison', target: Entity | null }, changed only through actions. beginTargeting(world, mode) MUST set the mode and select the first of validTargets for that mode's range (DND-041), and MUST stay 'none' when there is none, when it is not a hero turn, when the hero has acted, or (mode 'poison') when the hero has no PoisonStrike. cycleTarget(world, ±1) MUST step through validTargets with wrap-around. confirmTarget(world) MUST call attack or usePoison with the selected target and reset Targeting. cancelTargeting(world) resets it. turn/begin also resets it (DND-027).
  • DND-055 [ux][test]: Under the key rule of DND-016, A MUST call beginTargeting('attack') and Escape MUST call cancelTargeting. Tab, Shift+Tab, and Enter MUST be handled only while targeting is active (Targeting.mode is not 'none'): Tab / Shift+Tab then call cycleTarget(+1 / −1) and preventDefault (focus stays on the game area), and Enter calls confirmTarget. While targeting is not active, Tab and Shift+Tab MUST NOT be preventDefaulted (focus moves on to the action buttons, DND-056) and Enter MUST do nothing on the game area. Escape ends targeting and so releases Tab. In targeting mode the valid targets' cell labels MUST include the text "target", and the selected one "selected target" (DND-015).
  • DND-056 [ux][test]: An "Actions" group MUST hold an "Attack" button and the "End turn" button (DND-038), all keyboard reachable with Tab from the game area (DND-055). "Attack" MUST be disabled with the visible text "No target in reach" when validTargets is empty, and disabled outside a hero turn. Activating "Attack" MUST call beginTargeting('attack') and move focus to the game area, so Tab / Enter / Escape then follow DND-055. Enter or Space on a focused button MUST only activate that button and MUST NOT call confirmTarget (DND-016).
  • DND-057 [ux][test]: The combat log MUST be a role="log" region labelled "Combat log" with aria-live="polite", listing the text of every entry oldest to newest, updating live.
  • DND-058 [ux][test]: In targeting mode, clicking a valid target's cell MUST select it and confirm. Clicking any other cell MUST NOT confirm and MUST NOT move the hero.
  • DND-059 [ux][manual]: The dice MUST be readable as text in the log: every d20, the kept die, advantage, and crits are visible (DND-051), the newest entry is scrolled into view, and contrast is WCAG AA in both themes. Verify with the Acceptance checklist (A3).

DND-F04 Monster turns (lowest-HP hero)

  • DND-060 [ecs][test]: decideMonster(world, monster) MUST be a pure read that returns { target }: the living hero with the lowest Health.hp, ties by lowest entity.id(), or null if no hero is living. Calling it MUST NOT change the world. The AI systems MUST get monster decisions only through this function (brief §7).
  • DND-061 [ecs][test]: ai/move (Move) MUST move the active monster toward its target one orthogonal step at a time, at most Speed steps, each step into a free, in-bounds cell that strictly reduces the distance to the target. It MUST stop as soon as distance ≤ Attack.range, or no such cell exists. The preferred axis is the one with the larger absolute difference, ties on x. It MUST update Encounter.movesLeft. There is no pathfinding (OQ3, out of scope).
  • DND-062 [ecs][test]: A monster that already has its target in reach MUST NOT move.
  • DND-063 [ecs][test]: A monster whose every distance-reducing cell is blocked MUST stay where it is, MUST NOT throw, and its turn MUST still end normally.
  • DND-064 [ecs][test]: ai/decide (Act) MUST set Intent { kind: 'attack', target } when the target of DND-060 is in reach (DND-040), and leave Intent as 'none' otherwise. The attack is then resolved by combat/act under DND-043..049, including DND-045; the AI has no roll rules of its own. A monster attacks only its lowest-HP hero, not an in-reach hero of higher HP (OQ4).
  • DND-065 [ecs][test]: A monster turn in which the monster moved MUST append one 'move' entry with the name, the start cell, and the end cell ("Goblin 2 moves from (6,3) to (4,3)"). A turn without movement MUST NOT.
  • DND-066 [ecs][test]: Target choice MUST be re-evaluated on every monster turn: if hero HP changes between two monster turns, the later monster targets the new lowest. A Defeated hero MUST never be chosen.
  • DND-067 [ecs][test]: Only the active monster acts in its turn. Hero positions are never changed by the AI, and a template (PREFAB-003) never acts.
  • DND-068 [ecs][test]: AI behaviour MUST be deterministic: ties are broken by entity.id() and direction order, never by query order. Two runs with the same seed and the same hero inputs MUST give the same positions, healths, and log (DND-033).
  • DND-069 [ecs][test]: Scenario: with a seeded Rng and a hero policy of "end turn immediately", the encounter MUST reach status 'defeat' within 100 rounds, and every monster turn MUST terminate (each uses at most Speed steps and one attack).

DND-F05 Rogue poison, ticking Poisoned effect

  • DND-080 [ecs][test]: PoisonStrike { range, turns, damage } MUST be a trait from content that only the Rogue has in the default party (the Fighter and the Wizard MUST NOT have it).
  • DND-081 [ecs][test]: HasEffect MUST be a relation with data { turns, damage }, and Poisoned MUST be one effect entity per world, spawned by setup with Name "Poisoned" and the Effect tag. There MUST be exactly one such entity per world, created again after a restart (DND-034), and every HasEffect pair of a combatant MUST target it.
  • DND-082 [ecs][test]: usePoison(world, target) MUST, when status is 'ongoing', the active combatant is a hero with PoisonStrike that has not acted, and target is a living opposing combatant with 1 ≤ distance ≤ PoisonStrike.range, set Intent { kind: 'poison', target } and Encounter.acted = true, then end the turn as in DND-029 and return true. Otherwise it MUST return false, consume no dice, and change nothing. It MUST NOT target a defeated or friendly combatant.
  • DND-083 [ecs][test]: combat/act for an intent of kind 'poison' MUST add HasEffect(Poisoned) with { turns: PoisonStrike.turns, damage: PoisonStrike.damage } to the target, append an 'ability' entry ("Rogue uses Poison Strike on Goblin 2"), and roll no dice and deal no damage (OQ1).
  • DND-084 [ecs][test]: Poisoning a target that already has HasEffect(Poisoned) MUST NOT add a second pair. It MUST set the pair's data back to { turns, damage } (refresh, no stacking).
  • DND-085 [ecs][test]: effects/tick (Resolve, first system) MUST, for the active combatant only and for each of its HasEffect(Poisoned) pairs, set Health.hp to max(0, hp − damage) and turns to turns − 1, and remove the pair when turns reaches 0. So a poisoned combatant ticks once at the end of each of its own turns, including turns in which it did nothing (OQ2). It MUST append a 'poison' entry per tick ("Goblin 2 takes 2 poison damage (2 turns left)").
  • DND-086 [ecs][test]: A target poisoned with { turns: N, damage: D } MUST take exactly N ticks, one at the end of each of its next N own turns, for N × D total damage (before the 0 floor). The pair MUST be gone after the Nth tick, within the same Resolve, and the effect MUST NOT tick at other combatants' turns.
  • DND-087 [ecs][test]: A tick that brings hp to 0 MUST be followed by combat/defeat in the same Resolve (effects/tick runs before it, DND-025, SCH-013), so the combatant has Defeated before turn/end. The log MUST show the poison entry before the 'defeat' entry.
  • DND-088 [ecs][test]: Scenario for DND-045 (adds no rule): (a) an attack on a target without the effect rolls one d20; (b) after usePoison an attack on it rolls two d20 and its log text says "advantage"; © after the Nth tick has removed the pair, an attack on it rolls one d20 again. The same holds when a monster attacks a hero carrying the pair (added directly in the test).
  • DND-089 [ecs][test]: Observers registered by setup MUST append an 'effect' entry when a HasEffect pair is added ("Goblin 2 is poisoned (3 turns, 2 damage per turn)", from world.onAdd(HasEffect, …), which receives the entity and the target) and when it is removed ("Poison wears off Goblin 2"). Each event MUST log exactly once, also after a restart (DND-053).
  • DND-090 [ecs][test]: A pair added or removed during an action call (the hero action or any monster turn advance runs after it) MUST appear in Cues.effectsAdded or Cues.effectsRemoved after that call and stay there until the next accepted hero action (DND-035). Example: a pair the Rogue adds with usePoison is still in Cues.effectsAdded after the monster turns that follow.
  • DND-091 [ux][test]: A "Poison Strike" button MUST be visible only when the active hero has PoisonStrike. Key P on the game area (DND-016) MUST call beginTargeting('poison'), and the targeting controls of DND-055 and DND-058 MUST apply (Enter calls confirmTarget while targeting is active). Activating the button MUST call beginTargeting('poison') and move focus to the game area (as in DND-056). The button is disabled with "No target in reach" when validTargets for its range is empty.
  • DND-092 [ux][test]: A combatant with HasEffect(Poisoned) MUST show the text "Poisoned, N turns left" (N = pair turns) in its initiative item and in its grid cell label, and "(new)" while it is in Cues.effectsAdded (until the next accepted hero action, DND-035). The text MUST disappear on the next render after the pair is removed or the combatant is defeated. No state is conveyed by colour alone.
  • DND-093: retired. The ID was never assigned a requirement in the approved spec and is not reused.
  • DND-094 [ux][manual]: A user who plays a Rogue poison turn MUST be able to tell which monsters are poisoned, see the countdown, and see "advantage" in the log for later attacks on them (DND-051). Verify with the Acceptance checklist (A4).

DND-F10 Devtools panel

  • DND-100 [ecs][test]: At app start, once, the demo MUST call registerNames(world, …) (DEVTOOLS-001) for Name, Position, Health, ArmorClass, Speed, Attack, Initiative, PoisonStrike, Hero, Monster, Defeated, Active, Effect, and the relation HasEffect, under the names of the exports. getName MUST return each name, and the names MUST survive restartEncounter (DEVTOOLS-004). Repeating the call is harmless (DEVTOOLS-002).
  • DND-101 [ecs][test]: setupEncounter MUST call setEntityLabel(world, e => e.get(Name)?.value) (DEVTOOLS-005), so combatants and the Poisoned effect entity are labelled by name, and a pair target shows as "Poisoned" (DEVTOOLS-014).
  • DND-102 [ux][test]: The host MUST own the panel (DEVTOOLS-065): a "Devtools" toggle button (aria-pressed) and key I on the game area MUST mount and unmount <WorldInspector />. It is closed by default and no store exists while closed (DEVTOOLS-033). Whether it is open is view state in App.tsx.
  • DND-103 [ux][test]: On a fresh encounter with the panel open, the list MUST show exactly the heroes, the monster instances, and the Poisoned effect entity, each labelled by name with its registered trait badges (e.g. Health, Hero), and no templates. With "Show excluded" on it MUST also list one template per monster kind (DEVTOOLS-057, DEVTOOLS-080). The hidden world entity is not asserted.
  • DND-104 [ux][test]: Through real actions and without user action on the panel, with a monster selected (DEVTOOLS-058): after usePoison the detail MUST show a Pairs row "HasEffect → Poisoned" with the text { turns: N, damage: D } from content (DEVTOOLS-081). After each tick the text and the Health text MUST update. After the Nth tick the row MUST be gone. After defeat the list MUST show the Defeated badge (DEVTOOLS-082). The test drives time through the injected schedule and timer.
  • DND-105 [ux][test]: WorldInspector owns its store (ecs-devtools: "The component owns one store per mount") and WorldInspectorProps (ecs-devtools API sketch) has only world, options, and className, so the host has no refresh to call and MUST remount it on restart (a React key that changes with each restart). After a restart with the panel open and an entity selected, the panel MUST list the new encounter's combatants, show no old entity, and show "Select an entity" (DEVTOOLS-051).
  • DND-106 [ux][test]: The panel MUST NOT change the game (DEVTOOLS-072, L11): the same seeded, scripted fight (DND-033) MUST end with the same combatant state and the same log with the panel open and an entity selected as with it closed.
  • DND-107 [ux][test]: Keys MUST NOT leak into the game handler (key rule, DND-016): ArrowUp, ArrowDown, Tab, Shift+Tab, Enter, Escape, A, P, E, I, and R pressed while focus is inside the panel (DEVTOOLS-056) or on a button (DND-056) MUST trigger no game-key handler, so none of them calls moveActive, beginTargeting, cycleTarget, confirmTarget, cancelTargeting, or restartEncounter, or toggles the panel, and the game MUST NOT preventDefault them. This does not apply to a button's own native activation: Enter or Space on a focused button still clicks it and runs only that button's action ("End turn" calls endTurn once, DND-038; "Attack" calls beginTargeting('attack'), DND-056; DND-091; "Restart", DND-037). Test with real key events: a game key on a button or in the panel calls no game action, and Enter on "End turn" calls endTurn exactly once and nothing else.
  • DND-108 [ux][manual]: With the panel open at 1280×720, the grid, the initiative panel, the log, and the panel MUST all stay usable side by side, with no overlap, and the panel at least 320 px wide (DEVTOOLS-062). Verify with the Acceptance checklist (A5).
  • DND-109 [ux][manual]: With the panel open and a combatant selected (under 50 combatants, 12×12), the UI MUST stay at 60 FPS during a full monster turn (brief §6, DEVTOOLS-064). Verify with the Acceptance checklist (A6).

ECS model

The model follows demo-rules: content in src/content/, ECS only in src/ecs/, views in src/ui/<feature>/.

Content (src/content/)

Plain typed data, no logic. Starting values only (tuning, not requirements):

Entry hp AC Speed Attack (bonus, dice, damageBonus, range) Initiative bonus Extra
Fighter 20 16 4 +5, 1d8, +3, range 1 +1
Rogue 14 14 5 +5, 1d6, +3, range 1 +3 PoisonStrike range 1, 3 turns, 2 damage
Wizard 10 11 3 +5, 1d10, +0, range 4 +2
Goblin (template) 7 13 4 +4, 1d6, +2, range 1 +2
Orc (template) 15 12 3 +5, 1d12, +3, range 1 +0

party.ts (the three heroes and start cells), monsters.ts (the two templates), encounter.ts (grid size, e.g. 10×8, and the monster list: 3 Goblins and 1 Orc with start cells).

Traits (src/ecs/traits.ts)

Trait Kind Shape
Name SoA { value: string }
Position SoA { x, y }
Health SoA { hp, max }
ArmorClass SoA { value }
Speed SoA { value }
Attack SoA { bonus, diceCount, diceSides, damageBonus, range }
Initiative SoA { value }
PoisonStrike SoA { range, turns, damage }
Hero, Monster, Defeated, Active, Effect tags

Relations (src/ecs/relations.ts)

  • HasEffect: relation({ store: { turns: 0, damage: 0 } }), non-exclusive. Target: the Poisoned effect entity (Name "Poisoned", Effect).

World traits (singletons, added by setup)

Trait Shape
Encounter { round, status, movesLeft, acted }
Intent { kind: 'none' \| 'attack' \| 'poison', target: Entity \| null }
Targeting { mode: 'none' \| 'attack' \| 'poison', target: Entity \| null }
CombatLog AoS { entries: LogEntry[] }, replaced on every append (DND-050)
Cues AoS { defeated, effectsAdded, effectsRemoved }, accumulated over one action call and cleared at the next hero action (DND-035)
Rng AoS { next(): number } (DND-033)

Systems (src/ecs/systems.ts, schedule dnd/turn)

Phase Order System Gate Does
StartOfTurn 1 turn/begin movesLeft, acted, clear intent and targeting (DND-027)
Move 1 ai/move set dnd/monster-turn step toward the target (DND-061)
Act 1 ai/decide set dnd/monster-turn set the attack intent (DND-064)
Act 2 combat/act resolve attack or poison intent (DND-043, DND-083)
Resolve 1 effects/tick poison ticks for the active combatant (DND-085)
Resolve 2 combat/defeat add Defeated (DND-031)
Resolve 3 encounter/outcome victory or defeat (DND-032)
EndOfTurn 1 turn/cues append to Cues (DND-035)
EndOfTurn 2 turn/end next Active, round (DND-030)

Phases StartOfTurn → Move → Act → Resolve → EndOfTurn form one detached chain (DND-024). The set dnd/monster-turn spans Move and Act (SCH-130). Observers (registered by setup): defeat log (DND-052), effect log (DND-089).

Actions (src/ecs/actions.ts, createActions)

setupEncounter, restartEncounter, advance (internal driver), moveActive, attack, usePoison, endTurn, beginTargeting, cycleTarget, confirmTarget, cancelTargeting. Pure helpers: initiativeOrder, validTargets, decideMonster, rollDie, createSeededRng. Views never mutate traits; they call actions.

Views (src/ui/)

Folder Content Requirements
grid/ grid, cell labels, click to move or target DND-015, DND-018, DND-058
initiative/ order, round, fallen, outcome banner, restart DND-036, DND-037
actions/ Attack, Poison Strike, End turn, moves left DND-017, DND-038, DND-056, DND-091
log/ combat log DND-057
devtools/ host toggle and <WorldInspector /> DND-102, DND-105
input/ key handling on the game area DND-016, DND-055

Keys (only when the game area itself has focus, DND-016): arrows move, A attack, P poison strike, E end turn, Escape cancel, R restart (when over), I devtools. While targeting is active only: Tab / Shift+Tab cycle targets, Enter confirm (otherwise Tab moves focus to the buttons as usual). d stays the template's theme toggle. Hints on screen are DND-F07.

Differences from flecs

  • The effect is a koota relation with pair data { turns, damage } and an entity target (Poisoned). flecs would model it as a pair with a data component on the pair. The relation, not the target, carries the data type.
  • The turn pipeline is stepped by hand (runPhases with dt = 0) on a detached phase chain, not driven by progress(). This is the ecs-schedule difference (SCH-104, SCH-107), used as intended.
  • Monster templates are copied, not inherited: later template changes do not reach existing monsters (PREFAB-016, concept §4).
  • Observers are koota's onAdd/onQueryAdd, not flecs observers with events and iterators. world.onAdd also fires for prefab entities (PREFAB-008), so the demo uses query-based observers for anything that must ignore templates.
  • Singletons are world traits (Encounter, CombatLog, …), not flecs singleton components. Entities have no names; Name is an ordinary trait and the devtools label function reads it (DEVTOOLS-005).
  • Added/Removed are koota tracking-query modifiers owned by one system and reset on each read of that query, not flecs change detection on tables.

Out of scope

  • Everything in brief §8: terrain, cover, line of sight, opportunity attacks, party selection, leveling, inventory, status effects other than Poisoned, full 5e fidelity, 3D, multiplayer.
  • DND-F07 (on-screen key hints and keyboard polish beyond the keys listed), DND-F08 (save/resume, ecs-serialize), DND-F09 (monster variants, e.g. Goblin Boss, PREFAB-072), DND-F11 (party and encounter selection), DND-F13, DND-F14.
  • Disadvantage, saving throws, spell effects, ranged penalties, reactions, bonus actions, healing, and rests.
  • Diagonal movement and pathfinding (monsters step greedily, DND-061).
  • Pacing or animation of monster turns, sound, and drag and drop.
  • Moving after acting, and undo.
  • Hot-reload of content and any content other than the default party and encounter.
  • Any change to packages/ecs-*. A missing package feature becomes a package work item (see Open questions 7 and 8).

Open questions

Each has a recommended default. The requirements above already use the defaults. If the user decides otherwise, the named requirements change.

  1. Poison Strike: attack roll or automatic? Default: no roll, no damage; it applies Poisoned to a valid target (DND-083). Alternative: an attack roll, applying Poisoned on a hit (with advantage rules of DND-045) and optionally weapon damage. Affects DND-082, DND-083, DND-049, DND-088.
  2. When does Poisoned tick? Default: at the end of the poisoned combatant's own turn, in Resolve before the defeat check (DND-085), so no turn is skipped by a start-of-turn death. Alternative: start of its turn, which needs a defeat check right after StartOfTurn and a skip of Move/Act. Affects DND-085..087, DND-025.
  3. Distance and movement metric. Default: orthogonal steps, distance |dx| + |dy|, adjacent = 1 (DND-040, DND-010, DND-061), which keeps keyboard movement to the four arrow keys. Alternative: 8 directions with Chebyshev distance (needs diagonal keys).
  4. Monster targeting. Default: always the lowest-HP living hero, and it attacks only if that hero is in reach after moving (DND-060, DND-064). Alternative: attack the weakest hero in reach when the weakest overall is not reachable.
  5. One action per turn, no moving after it. Default: acting ends the hero's turn (DND-029). Alternative: allow remaining movement after the action, needing an explicit end of turn.
  6. Monster turns are instant. Default: all consecutive monster turns resolve inside one action call and show up in the log (DND-028, DND-039). Pacing or animation is M2 polish. Alternative: step one monster per key press or timer tick.
  7. WorldInspector has no refresh. The host remounts it on restart (DND-105). If this proves awkward (e.g. flicker, lost selection), the fix is a package work item on ecs-devtools (expose the store or a refresh prop), not a demo workaround.
  8. ecs-prefab OQ6 (relation-backed traits in world.traits). Not blocking for M1: templates carry no relation pairs, so the copy loop never meets a relation-backing trait. It becomes relevant with variants (DND-F09, PREFAB-072) and must be settled in ecs-prefab before its implementation (W-038..W-042).
  9. Poisoned effect entity in the inspector. Default: it is an ordinary entity, so it appears in the devtools list (DND-103). Alternative: add IsExcluded to it (verify that a pair to an excluded target still reads in devtools first).
  10. Initiative and ties. Default: d20 + content bonus rolled once at setup, ties by entity id (DND-020, DND-021). Alternative: fixed initiative from content (no dice in setup), or ties broken by bonus.
  11. Content numbers. The Content table gives starting values. If balance needs a different fight length (2–5 minutes), only content changes, not this spec.
  12. Key map. Default: A, P, E, R, I, arrows, Tab/Enter/Escape (see Views). Alternative: another mapping. DND-F07 adds on-screen hints.
  13. Restart via world.reset(). koota's React test shows useQuery surviving a reset, but useTrait on world traits is not covered by koota's tests. DND-037 (after restart the UI shows the new encounter) is the canary for it. If it fails, restart becomes "destroy combatants and re-add world traits" or a new world, and DND-034 changes.

Acceptance checklist

A human runs these after the feature work items (W-019..W-024) are done. Each box covers one [manual] requirement. Setup for all: pnpm install, then pnpm --filter demo-dnd dev, and open the printed local URL in Chrome or Edge.

  • A1 (DND-019): grid layout and contrast.
  • Set the viewport to 1024×768. Start an encounter with the default content. Expected: the whole grid, the initiative panel, and the action controls are visible without scrolling the page.
  • Read every cell label on the grid, and tell heroes from monsters without colour (labels say "hero" and "monster"). Expected: all labels are readable and unambiguous.
  • Run a Lighthouse Accessibility audit in light mode, press d (focus outside any input) to switch to dark mode, and run it again. Expected: no contrast finding inside the grid in either theme.
  • Temporarily set the content grid to 12×12 (revert afterwards). Expected: the grid still fits the 1024×768 viewport.
  • A2 (DND-039): monster turns feel instant and are followable.
  • Play a hero turn and press E. Expected: the monster turns appear in the log in initiative order immediately, and the next hero becomes active.
  • Compare the log with the grid for one monster turn. Expected: the "moves from (x,y) to (x,y)" entries match the new positions, and attacks show their rolls.
  • In the browser console, time one round with a content grid of 12×12 and about 49 combatants (e.g. performance.now() around a test run of endTurn for a hero when all others are monsters, or a temporary benchmark in a test). Expected: under 16 ms for the whole round of logic.
  • A3 (DND-059): dice readable in the log.
  • Make an attack on a monster. Repeat until you see a hit, a miss, and (use a seeded fight if needed) a critical hit. Expected: the log text shows every d20, the bonus, the total, the AC, and the word hit, miss, or critical hit (plus the damage on a hit).
  • Attack a Poisoned monster (see A4). Expected: two d20 values and the word "advantage" appear in the entry.
  • Add many entries (play a few rounds). Expected: the newest entry stays in view.
  • Run a Lighthouse Accessibility audit on the log in light and dark mode. Expected: no contrast finding inside the log.
  • A4 (DND-094): poison is understandable.
  • Play until the Rogue is active (restart until the Rogue has a monster in reach if needed) and press P, then confirm a monster in reach with Enter (Tab cycles targets only while targeting). Expected: the monster shows "Poisoned, 3 turns left" in the initiative panel and on its grid cell, with "(new)" still shown after the monsters' turns that follow, and until the next hero action.
  • Attack that monster with another hero. Expected: the log entry says "advantage" and shows two d20 values.
  • Let the monster take its turns. Expected: the countdown goes 3, 2, 1, then the text disappears, the log shows the poison damage entries and "Poison wears off", and later attacks on it roll one d20.
  • A5 (DND-108): panel beside the game.
  • Set the viewport to 1280×720. Press I on the game area (or press the "Devtools" button) to open the panel. Expected: the grid, initiative panel, log, and panel are all visible side by side, with no overlap, the panel at least 320 px wide.
  • Play one turn with the panel open. Expected: the game stays fully usable, and arrow keys typed inside the panel list do not move a hero.
  • Repeat the check in the other theme (press d). Expected: the same, and all panel text is readable.
  • A6 (DND-109): 60 FPS with the panel open.
  • Start an encounter near the brief's upper bound (about 40 to 49 combatants on a 12×12 grid, via temporary content), open the panel, and select one combatant.
  • In Chrome devtools, Performance tab, click Record, press E to play one full round of monster turns, then stop. Expected: the FPS chart stays at 60 with no red bars, and no frame in the Frames track is longer than 16.7 ms.
  • In the same recording, look for Long Tasks (over 50 ms) from the panel or the game. Expected: none.
  • Repeat with the panel closed. Expected: the panel adds no visible frame drops compared with the closed run.