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 (runskips manual phases,runPhase/runPhasesstep 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 (schedulename). Thednd/turnexample 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 afterworld.reset()), PREFAB-070 and PREFAB-073 (demo scenario), PREFAB-008 (world.onAddfires for prefabs). - ecs-devtools: DEVTOOLS-001..005 (names and labels), DEVTOOLS-014 (pairs), DEVTOOLS-033, the
WorldInspectorPropssketch and the React panel text "owns one store per mount" (store lifetime, norefreshprop), 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
kootaskill andreference/koota): trait,relation({ store }),createWorld,world.reset(), world traits throughworld.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.useQueryis tested by koota to surviveworld.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 ofHeroorMonster. Monster templates and thePoisonedeffect entity (DND-081) are not combatants. A combatant is living if it lacksDefeated. 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
Activetag. 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 ofcontent.party(default: Fighter, Rogue, Wizard). Each hero MUST haveHero,Name,Health(hpandmaxboth the content hp),ArmorClass,Speed,Attack, andPosition, 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), carryingMonsterand the kind's stats. It MUST then create one monster per entry ofcontent.encounter.monsterswithinstantiate(PREFAB-010, PREFAB-011), overridingName(unique per monster, e.g. "Goblin 2") andPosition(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 haveIsA(template)(PREFAB-012). - DND-005 [ecs][test]: After setup, every combatant's
PositionMUST 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.setupEncounterMUST throw, namingsetupEncounterand 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
setupEncounterand assert that the spawned values equal it. Systems and actions MUST NOT contain stat literals. - DND-010 [ecs][test]:
moveActive(world, dir), withdirone of'up' | 'down' | 'left' | 'right', MUST move the active hero one cell and returntrueiff all hold: status is'ongoing', the active combatant is a hero,Encounter.actedisfalse,Encounter.movesLeft ≥ 1, and the target cell is inside the grid and not occupied by a living combatant. It MUST decrementmovesLeftby 1 and writePositionwithset. - DND-011 [ecs][test]: In every other case
moveActiveMUST returnfalseand change nothing. One test each: out of bounds, cell occupied by a hero, cell occupied by a monster,movesLeft0, already acted, noActive, the active combatant is a monster, status not'ongoing'. - DND-012 [ecs][test]: A hero with
Speeds 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 returnfalse. - DND-013 [ecs][test]: A
Defeatedcombatant 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 × heightcells. 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 callmoveActiveandpreventDefault(no page scroll). Key rule (the only one, DND-055 and DND-091 refer to it): game keys MUST be handled by akeydownhandler on the game area, not onwindow, and only whenevent.targetis 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 bepreventDefaulted 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 ignoreevent.repeatevents: a held key calls no action, and a repeat of a key that ispreventDefaulted on its first press (arrows, andTabwhile targeting, DND-055) is stillpreventDefaulted, 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
moveActivecall. 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 contentinitiativeBonus. Each monster instance MUST get its own roll as an override atinstantiate(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 byInitiative.valuedescending, ties byentity.id()ascending. The order never relies on query iteration order (ecs-schedule "Notes for system authors"). - DND-022 [ecs][test]: Setup MUST add the
Encounterworld trait withround: 1andstatus: 'ongoing', putActiveon the first entry ofinitiativeOrder, and then runadvance(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 haveActive. 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 phasesStartOfTurn → Move → Act → Resolve → EndOfTurn, each after the previous one throughdependsOnand 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 withrunPhase/runPhasesanddt = 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 fromafteredges (SCH-013). - DND-026 [ecs][test]:
ai/moveandai/decideMUST be members of the setdnd/monster-turn(SCH-126, SCH-127), whoserunIfistrueiff status is'ongoing'and the active combatant is a monster withHealth.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 setEncounter.movesLeftto the active combatant'sSpeed,Encounter.actedtofalse, and clearIntentandTargeting(DND-054). - DND-028 [ecs][test]:
advance(world)MUST loop while status is'ongoing': runStartOfTurn; if the active combatant is a monster, runrunPhases(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 noActive. Consecutive monster turns MUST resolve synchronously in initiative order within one call (OQ6).advanceMUST NOT clearCues: eachturn/cuesrun adds to them (DND-035). - DND-029 [ecs][test]: A hero turn MUST end when the hero acts (
attackDND-042,usePoisonDND-082) or callsendTurn(world):ActthroughEndOfTurnrun viarunPhases(world, Act, EndOfTurn, 0), thenadvance. After acting, the hero cannot move or act again that turn (OQ5).endTurnconsumes no dice and logs nothing. Both returnfalseand change nothing unless status is'ongoing'and the active combatant is a hero. Every hero action that returnstrue(moveActive,attack,usePoison,endTurn) MUST clearCuesfirst, before it changes anything else (DND-035); an action that returnsfalseleavesCuesuntouched. - DND-030 [ecs][test]:
turn/end(EndOfTurn) MUST moveActiveto 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 incrementingEncounter.roundwhen it passes the end. Example: A, B, C with B defeated: after A comes C; after C comes A andround+ 1. When status is not'ongoing'it MUST only removeActive. - DND-031 [ecs][test]:
combat/defeat(Resolve) MUST addDefeatedto every combatant withHealth.hp0 that lacks it. A defeated combatant MUST NOT appear ininitiativeOrder, 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, aftercombat/defeat) MUST set status'victory'when every monster isDefeated, and'defeat'when every hero isDefeated. When the status is not'ongoing',advanceMUST stop, and every hero action (moveActive,attack,usePoison,endTurn) MUST returnfalse. - DND-033 [ecs][test]: All randomness MUST go through the world trait
Rng(an AoS trait holding{ next(): number }in[0, 1)) androllDie(world, sides)=1 + floor(next() * sides).createSeededRng(seed)MUST give a deterministic sequence.setupEncounter(world, content?, { seed? })MUST install a seededRngwhen given a seed, and tests MUST be able to install a scriptedRng. 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 afterrestartEncounter(DND-034). - DND-034 [ecs][test]:
restartEncounter(world)MUST callworld.reset()and thensetupEncounterwith the same content and the same seed as the originalsetupEncountercall (DND-033), so a restart replays the same fight for the same hero inputs.setupEncounterMUST remember that content and seed for the world outside the ECS traits (e.g. aWeakMapkeyed by world inactions.ts), becauseworld.reset()removes every trait. TheRngis installed again freshly from the seed (its sequence starts over); if setup got no seed, the restart is unseeded as well. A scriptedRngthat a test installed by hand is not kept. Afterwards: all old entity handles are dead (PREFAB-066), round is 1 and status'ongoing',CombatLogis empty, templates and thePoisonedeffect 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 logkindandtextsequence as in the first run. - DND-035 [ecs][test]:
turn/cues(EndOfTurn, beforeturn/end) MUST write the world traitCueswithdefeated,effectsAdded, andeffectsRemoved(arrays of entities) fromAdded(Defeated),Added(HasEffect), andRemoved(HasEffect)queries (createAdded/createRemovedinstances owned by the system). One action call (a hero action and all the monster turnsadvanceruns after it, DND-028) runsturn/cuesonce per turn, and each run MUST append its changes to the arrays already inCues(world.setwith new arrays, in order, no duplicates).CuesMUST 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 callCueshold 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 callCues.defeatedstill 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 (heroormonster), andhp/max. It MUST show "Round N". The active item MUST havearia-current="true"and the text "Active". Defeated combatants MUST appear in a separate list "Fallen". An entity inCues.defeatedMUST 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 arole="status"banner with the text "Victory" or "Defeat" and a "Restart" button. Activating it (or pressingRon the game area) MUST callrestartEncounterand 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 tobodyand can pressR. - DND-038 [ux][test]: An "End turn" button (key
Eon the game area) MUST callendTurn. It MUST be disabled (andaria-disabled) whenendTurnwould returnfalse. - 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]:
AttackMUST havebonus,diceCount,diceSides,damageBonus, andrange. A target is valid for an attacker with range r iff both are living combatants, they are opposing, and1 ≤ distance ≤ r. Melee combatants haverange1 (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, thenentity.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, andtargetis valid for itsAttack.range, setIntent { kind: 'attack', target }andEncounter.acted = true, then end the turn as in DND-029 and returntrue. Otherwise it MUST returnfalse, consume no dice, and add no log entry. - DND-043 [ecs][test]:
combat/act(Act) MUST resolve anIntentof kind'attack'for the active combatant, hero or monster, under DND-044..049, and then clearIntent. 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 andtotal ≥ ArmorClass.valueof 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 × diceCountdice ofdiceSides), and adddamageBonusonce. - 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.hpMUST becomemax(0, hp − damage), written withset, andHealth.maxMUST NOT change. A miss MUST NOT changeHealth. AddingDefeatedat 0 hp iscombat/defeat's job in the same turn (DND-031), notcombat/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
Rngtest asserts the exact consumption for hit, miss, crit, advantage, and nat-1 cases, and that an invalidattack(DND-042) consumes none. - DND-050 [ecs][test]: The combat log MUST be the world trait
CombatLogwithentries, each{ id, round, kind, text, data }, ids strictly increasing, in chronological order. Entries MUST be appended withworld.setand a new array souseTraitre-renders.kindis one of'attack','move','ability','effect','poison','defeat','outcome'. - DND-051 [ecs][test]: Each attack MUST append one
'attack'entry whosedatahas{ attacker, target, rolls, kept, bonus, total, ac, advantage, outcome, damage }(outcomeis'hit' | 'miss' | 'crit';rollshas every d20 rolled). ItstextMUST contain both names, every d20 value, the bonus, the total, the AC, the word "advantage" iffadvantageistrue(DND-045), the outcome as a word (hit,miss, orcritical 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.onQueryAddonDefeated, neverworld.onAdd, PREFAB-005, PREFAB-008) MUST append one'defeat'entry "X is defeated" per combatant that becomesDefeated, 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 ofvalidTargetsfor 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 noPoisonStrike.cycleTarget(world, ±1)MUST step throughvalidTargetswith wrap-around.confirmTarget(world)MUST callattackorusePoisonwith the selected target and resetTargeting.cancelTargeting(world)resets it.turn/beginalso resets it (DND-027). - DND-055 [ux][test]: Under the key rule of DND-016,
AMUST callbeginTargeting('attack')andEscapeMUST callcancelTargeting.Tab,Shift+Tab, andEnterMUST be handled only while targeting is active (Targeting.modeis not'none'):Tab/Shift+Tabthen callcycleTarget(+1 / −1)andpreventDefault(focus stays on the game area), andEntercallsconfirmTarget. While targeting is not active,TabandShift+TabMUST NOT bepreventDefaulted (focus moves on to the action buttons, DND-056) andEnterMUST do nothing on the game area.Escapeends targeting and so releasesTab. 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
Tabfrom the game area (DND-055). "Attack" MUST be disabled with the visible text "No target in reach" whenvalidTargetsis empty, and disabled outside a hero turn. Activating "Attack" MUST callbeginTargeting('attack')and move focus to the game area, soTab/Enter/Escapethen follow DND-055.EnterorSpaceon a focused button MUST only activate that button and MUST NOT callconfirmTarget(DND-016). - DND-057 [ux][test]: The combat log MUST be a
role="log"region labelled "Combat log" witharia-live="polite", listing thetextof 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 lowestHealth.hp, ties by lowestentity.id(), ornullif 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 mostSpeedsteps, each step into a free, in-bounds cell that strictly reduces the distance to the target. It MUST stop as soon asdistance ≤ Attack.range, or no such cell exists. The preferred axis is the one with the larger absolute difference, ties on x. It MUST updateEncounter.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 setIntent { kind: 'attack', target }when the target of DND-060 is in reach (DND-040), and leaveIntentas'none'otherwise. The attack is then resolved bycombat/actunder 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
Defeatedhero 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
Rngand 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 mostSpeedsteps 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]:
HasEffectMUST be a relation with data{ turns, damage }, andPoisonedMUST be one effect entity per world, spawned by setup withName"Poisoned" and theEffecttag. There MUST be exactly one such entity per world, created again after a restart (DND-034), and everyHasEffectpair of a combatant MUST target it. - DND-082 [ecs][test]:
usePoison(world, target)MUST, when status is'ongoing', the active combatant is a hero withPoisonStrikethat has not acted, andtargetis a living opposing combatant with1 ≤ distance ≤ PoisonStrike.range, setIntent { kind: 'poison', target }andEncounter.acted = true, then end the turn as in DND-029 and returntrue. Otherwise it MUST returnfalse, consume no dice, and change nothing. It MUST NOT target a defeated or friendly combatant. - DND-083 [ecs][test]:
combat/actfor an intent of kind'poison'MUST addHasEffect(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 itsHasEffect(Poisoned)pairs, setHealth.hptomax(0, hp − damage)andturnstoturns − 1, and remove the pair whenturnsreaches 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 sameResolve, and the effect MUST NOT tick at other combatants' turns. - DND-087 [ecs][test]: A tick that brings
hpto 0 MUST be followed bycombat/defeatin the sameResolve(effects/tickruns before it, DND-025, SCH-013), so the combatant hasDefeatedbeforeturn/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
usePoisonan 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 aHasEffectpair is added ("Goblin 2 is poisoned (3 turns, 2 damage per turn)", fromworld.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
advanceruns after it) MUST appear inCues.effectsAddedorCues.effectsRemovedafter that call and stay there until the next accepted hero action (DND-035). Example: a pair the Rogue adds withusePoisonis still inCues.effectsAddedafter the monster turns that follow. - DND-091 [ux][test]: A "Poison Strike" button MUST be visible only when the active hero has
PoisonStrike. KeyPon the game area (DND-016) MUST callbeginTargeting('poison'), and the targeting controls of DND-055 and DND-058 MUST apply (EntercallsconfirmTargetwhile targeting is active). Activating the button MUST callbeginTargeting('poison')and move focus to the game area (as in DND-056). The button is disabled with "No target in reach" whenvalidTargetsfor its range is empty. - DND-092 [ux][test]: A combatant with
HasEffect(Poisoned)MUST show the text "Poisoned, N turns left" (N = pairturns) in its initiative item and in its grid cell label, and "(new)" while it is inCues.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) forName,Position,Health,ArmorClass,Speed,Attack,Initiative,PoisonStrike,Hero,Monster,Defeated,Active,Effect, and the relationHasEffect, under the names of the exports.getNameMUST return each name, and the names MUST surviverestartEncounter(DEVTOOLS-004). Repeating the call is harmless (DEVTOOLS-002). - DND-101 [ecs][test]:
setupEncounterMUST callsetEntityLabel(world, e => e.get(Name)?.value)(DEVTOOLS-005), so combatants and thePoisonedeffect 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 keyIon 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 inApp.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
Poisonedeffect 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
usePoisonthe 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 theHealthtext MUST update. After the Nth tick the row MUST be gone. After defeat the list MUST show theDefeatedbadge (DEVTOOLS-082). The test drives time through the injectedscheduleandtimer. - DND-105 [ux][test]:
WorldInspectorowns its store (ecs-devtools: "The component owns one store per mount") andWorldInspectorProps(ecs-devtools API sketch) has onlyworld,options, andclassName, so the host has norefreshto call and MUST remount it on restart (a Reactkeythat 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, andRpressed while focus is inside the panel (DEVTOOLS-056) or on a button (DND-056) MUST trigger no game-key handler, so none of them callsmoveActive,beginTargeting,cycleTarget,confirmTarget,cancelTargeting, orrestartEncounter, or toggles the panel, and the game MUST NOTpreventDefaultthem. This does not apply to a button's own native activation:EnterorSpaceon a focused button still clicks it and runs only that button's action ("End turn" callsendTurnonce, DND-038; "Attack" callsbeginTargeting('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, andEnteron "End turn" callsendTurnexactly 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: thePoisonedeffect 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 (
runPhaseswithdt = 0) on a detached phase chain, not driven byprogress(). 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.onAddalso 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;Nameis an ordinary trait and the devtools label function reads it (DEVTOOLS-005). Added/Removedare 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.
- 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.
- When does Poisoned tick? Default: at the end of the poisoned combatant's own turn, in
Resolvebefore 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 afterStartOfTurnand a skip ofMove/Act. Affects DND-085..087, DND-025. - 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). - 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.
- 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.
- 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.
WorldInspectorhas norefresh. 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 arefreshprop), not a demo workaround.- 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). - Poisoned effect entity in the inspector. Default: it is an ordinary entity, so it appears in the devtools list (DND-103). Alternative: add
IsExcludedto it (verify that a pair to an excluded target still reads in devtools first). - 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.
- Content numbers. The Content table gives starting values. If balance needs a different fight length (2–5 minutes), only content changes, not this spec.
- Key map. Default:
A,P,E,R,I, arrows,Tab/Enter/Escape(see Views). Alternative: another mapping. DND-F07 adds on-screen hints. - Restart via
world.reset(). koota's React test showsuseQuerysurviving a reset, butuseTraiton 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 ofendTurnfor 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, orcritical 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 withEnter(Tabcycles 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
Ion 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
Eto 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.