Combat
Explanation. Back to the architecture overview.
A fight runs in its own mode. combat is the fourth AppMode, beside
play, build, and library. Like Library mode, combat mode replaces the
map columns. The body.mode-combat class hides the map, the Play sidebar,
and the authoring rails, and the full-width combat screen takes their place.
The header’s mode switch has no button for combat mode. The app enters the
mode when a fight starts and leaves it when the fight ends, in whatever way
the fight ends. During a fight, the ribbon’s Back to map control and the
sidebar’s Initiative card move a tab between the map and the screen. If no
fight is running, sessionControls.js turns a request for combat mode into
Play mode, so a stale setMode call cannot show an empty screen.
Combat mode works for the GM and for a player. A tab that switches to the
Player role leaves Build mode and Library mode, because those modes are for
authoring. The same guard in sessionControls.js keeps a player on the
combat screen, because the player takes the turn of their own character
there. A player tab hides the header’s mode switch, so a player gets to the
map through the ribbon’s Back to map control.
The wiring layer and Entities document the 5e resolution of an attack, a cast, and a damage application.
Modules
src/combat/Initiative.js ..... pure: the order, the round counter, the turn
pointer, and the turn advance
src/combat/ActionBudget.js ... pure: what one combatant already spent on the
current turn, and what a turn start gives back
src/combat/TurnActions.js .... pure: the standard actions, Cunning Action,
Second Wind, and Action Surge
as action bar entries, and their log lines
src/combat/CombatView.js ..... pure: projects a CombatState into rows a
panel can draw (side, HP, AC, defeated,
who can act), plus the fight's outcome
src/combat/FightEnd.js ....... pure: the outcome, the foes still standing,
and the XP of the defeated foes
src/combat/AttackResolve.js .. pure: the hit and crit table, the damage dice,
and the attacker's stats and proficiency
src/combat/AttackOptions.js .. pure: which per-swing options a weapon allows
(Sneak Attack, the two-handed grip)
src/combat/TwoWeapon.js ...... pure: the off-hand swing of two-weapon fighting
src/combat/Reactions.js ...... pure: what a reaction can spend itself on
src/combat/LegendaryResistance.js pure: the uses of Legendary Resistance left,
spent, and given back
src/combat/Cover.js .......... pure: the AC bonus of half and three-quarters
cover
src/combat/Loadout.js ........ pure: what a combatant wears, swings, and
keeps in slots, and how much of that a
given viewer can see
src/combat/Arrival.js ........ pure: the text of the alert for the hostile
creatures on a tile the party walks onto
src/combat/InitiativeRoll.js . pure: one initiative roll as a DEX check, with
the slant, the exhaustion penalty, and a note
src/combat/RefreshScheduler.js pure: one deferred refresh for a burst of
writes, with an injectable scheduler
src/combat/FocusRestore.js ... pure: names a control so a rebuild can give
focus back to its twin, with fallbacks
src/combat/HPLines.js ........ pure: the log lines for a damage or heal the
GM applies from the amount field
src/combat/SaveLines.js ...... pure: the GM and player versions of a save
spell's log lines
src/combat/SpentCost.js ...... pure: the note on an action bar button whose
cost the turn already spent
src/combat/FightMarkers.js ... pure: the foe tiles that the fight map marks
src/view/CombatSelection.js .. pure: the turn key, and how long the picked
target stays held
src/map/FightFrame.js ........ pure: the tile size and offsets that center
the fight map on the party
src/ui/CombatSetup.js ........ the setup dialog: one initiative row per
combatant, and the Roll initiative fill
src/ui/CombatScreen.js ....... the screen: composes the columns, the board,
the outcome banner, the live region, and the
focus handoff after a rebuild
src/ui/CombatRibbon.js ....... the turn ribbon: the round heading, one chip
per participant, the turn controls, and the
roving tab stop helpers
src/ui/CombatActiveColumn.js . the left column: the inspected combatant's
facts, HP controls, chips, concentration,
death saves, loadout, and action bar
src/ui/CombatMap.js .......... the read-only map of the fight area in the
Map tab
src/ui/CombatLog.js .......... the log column: a role="log" list that only
adds the rows logged since its last update
src/ui/CombatantCard.js ...... one board card, which is also a target-picker
button when given an onSelect
src/ui/LoadoutBlock.js ....... a loadout as labelled lines, shared by the
cards and the active column
src/ui/CombatActionBar.js .... the active combatant's weapons, spells, and
turn actions as buttons, grouped by kind and
spell level, under the budget pips
src/ui/InitiativePanel.js .... the sidebar card: one status line plus the
Open combat button
src/app/combatWiring.js ...... mounts the screen, keeps its transient UI
state, and sends everything else to actions
src/app/encounterWiring.js ... the only writer of state.combat, with the turn
flow as registered actions
src/app/turnAdvance.js ....... the turn advance, with the turn boundaries
that it passes on the way
src/app/turnActions.js ....... takes a turn action, and the GM's pip
override on the budget
src/app/turnEffects.js ....... what the start and the end of one turn do:
repeated saves, later-turn damage, and
chips that end at a boundary
src/app/combatEnd.js ......... the End combat confirmation and the XP award
styles/combat.css ............ the mode's layout and the screen's styles
The module that writes the fight
encounterWiring.js owns the running fight. The fight lives only in
state.combat. Every read goes through a current() accessor and every
write goes through setCombat, and both accessors are local to that module.
A cross-tab rehydrate writes state.combat and nothing else. A follower tab
whose fight ended in another tab therefore reads the ended fight from the
same field on its next refresh. A second copy of the fight, such as a
closure variable beside the state field, would miss that write and keep
drawing the old order.
The turn advance and the combat end are registered on app.actions as
advanceCombatTurn and endCombat. The screen’s Next turn and End combat
buttons run the same code as every other caller, including the round-wrap
condition ticks and the concentration sweeps. removeCombatant follows the
same pattern, so anything that changes the fight goes through the module
that owns it.
Names in the log
Two foes of one name get a number on their cards (“Gray Wolf 1”, “Gray
Wolf 2”). combatLabels in src/app/combatants.js numbers them across the
whole running order. The combatant that findCombatant returns has a
label getter over those labels, and logName(app, target) gives the same
label for a target by id. Every log and toast line in the combat modules
uses one of the two. A line built from entity.name would read “Gray Wolf”
for both wolves, and a source-text test in tests/uiVocabulary.test.js
fails on it. The fight-end lines run after the fight clears, so
fightSummary in app/combatEnd.js takes the labels while the order
still exists.
The turn advance
advanceTurn in Initiative.js takes a predicate and steps the pointer past
every combatant that the predicate rejects. CombatView.skipsTurn is that
predicate. It covers a downed combatant, a combatant whose chips cost it the
turn (Stunned or Lethargic, for example), and a participant id that
resolves to nothing. A dying character is the exception. Its Unconscious
chip would cost it the turn, but skipsTurn returns false while
isDying is true, so the character gets the turn on which it rolls its
death save. The deathSave flag of the turn budget allows one roll per
turn, and rollDeathSaveFor refuses a roll off that turn. A stable
character, and one at 0 HP with no tracker, still skip.
A defeated goblin never gets a turn, but its chip stays in the ribbon, struck
through. A stunned goblin also keeps its place, marked with a dashed edge
instead of a strike, because it is still in the fight and only its turn is
gone. A dying or stable character at 0 HP gets the dashed edge too, with no
strike. downState in view/DeathSaveView.js picks the mark and the words
of the accessible name for the ribbon chip and the card. It returns “dying”
or “stable at 0 HP” for a character with a death save tracker, “defeated”
for any other defeated combatant, and “cannot act” for a combatant whose
chips cost it the turn. If the predicate rejects every participant, the pointer walks one full
cycle and stops where it started. The round counter and the timed effects
then keep moving until the GM closes the fight.
app/turnAdvance.js wraps the advance in advancePastHeld, which runs the
turn boundaries that the advance passes (see app/turnEffects.js). The end
of a turn rolls the repeated saves of the combatant, such as the save that
ends Hold Person. It then deals the damage that the chips of the combatant
leave for later turns, such as the acid of Acid Arrow, and counts the
boundary for every chip keyed to that turn. The start of a turn counts the
boundary, and then a chip with mods.tempHPEachTurn (Heroism) grants its
temporary hit points to the combatant.
advancePastHeld runs its work in this order:
- The turn that ends runs its end-of-turn work.
- The pointer moves from
state.combatas that work left it. Damage can end a spell whose summons then leave the order, and a pointer moved from a copy taken earlier would write those summons back. - The new order is stored.
- Each combatant that the pointer stepped past starts and ends its turn. A paralyzed target still has a turn that ends, so its retry rolls there. Without that roll, it stays held for the whole duration. When the round wraps, the skipped combatants below the old pointer take their turns first, then the round ticks, and then the skipped combatants at the top of the order take theirs.
- The combatant that the pointer lands on starts its turn.
A dead character, a defeated creature, or a missing combatant rolls no save
and takes no damage, but the chips keyed to its turns still count the
boundary. A dying character at 0 HP still has its turn end, and the damage
of a chip costs it a failed death save, as any hit does. The start of a fight
starts the first turn. A removal from the fight ends the chips keyed to the
removed combatant, and it starts the turn of the next combatant when the
removed one held the turn. The end of a fight ends every chip that waits on
a turn boundary, because no turn comes again. endFightEffects first deals
the later-turn damage that such a chip still owes, so an Acid Arrow that hit
on the last turn still burns. A chip that deals damage only on a failed
repeated save deals none at the end of a fight.
A chip that only counts a boundary down, such as a chip with two ends left, still writes the new count back. An entity whose chips no boundary touches keeps its identity, and so does a roster with no change, because the roster indexes and the pack cache of the save key on that identity.
The round wrap
The round wrap runs TimedEffects.passRound over every character and every
creature, bystanders included. It takes one round off the timed chips, the
timed stat modifiers, and a held concentration. An entity with nothing timed
comes back as the same object, and a collection with no changed entity keeps
its array.
The save packs each entity through a cache keyed on the object. A new object per entity per round misses that cache. With 1,200 creatures, the round-tick save costs about 180 ms with new objects and about 3 ms with the kept ones.
Per-tab UI state
combatWiring.js owns two pieces of per-tab UI state, which never persist.
inspectedId is the combatant that the left column inspects. The ribbon
chips pick it, and null means whoever’s turn it is. selectedTargetId is the
board card that the GM or player picked as the target.
The app never validates the inspection, because a user can inspect a
defeated combatant on purpose. An id that stops resolving falls back to
whoever’s turn it is. The app releases the target on the refresh that shows
it defeated, out of the order, or with the fight over. Otherwise the card of
a defeated foe would keep its pressed ring after the attack dialog stopped
using the pick. heldTarget in view/CombatSelection.js also releases the
target when the turn changes, because a target picked for one combatant’s
attack would otherwise open the next combatant’s dialogs and HP box on the
same card, which can be that combatant’s own card. turnKey names a turn
by the round and the id of the turn holder, and the HP box of the active
column resets its amount on the same key. The key leaves out the index into
the order, because a summon sorted above the holder, or a drop earlier in
the order, moves that index in the middle of one turn.
The action budget
A 5e turn has one action, one bonus action, and one reaction.
src/combat/ActionBudget.js is the pure model of what a combatant already
spent. The budget lives on the participant, in a used field, so it saves
and resumes with the fight. It records what is gone instead of what is left.
A participant with no used field therefore reads as a whole turn through
budgetOf.
attacksLeft is the only counter in the budget. Extra Attack gives two
swings for one action, so the first swing spends the action and banks the
rest. The Multiattack of a creature banks its swings the same way,
through CreatureAttacks.swingsPerAction. spendAttack draws on the bank before it spends another action, and
attacksAvailable reports how many swings are left. Only a weapon whose own
count is two or more draws on the bank (see
Hit riders and the pact weapon). Each swing also sets
attacked, because a cast spends the action flag too, and two-weapon
fighting needs to know that the action went to the Attack action.
A chip with mods.extraAction (Haste) gives one more swing, which extra
records. spendAttack and attacksAvailable take an extraAction flag, and
the extra swing comes only once the action and the bank are both spent. So a
cast on the action still leaves the extra swing, and nothing banks behind it.
No other cost reads the flag, because the extra action of Haste can’t pay for
a cast.
advanceTurn gives a whole budget back to the combatant the pointer lands
on. The reaction resets there and not at the top of the round, because a 5e
combatant gets its reaction back at the start of its own turn. A combatant
that the pointer skips keeps its spent budget, because it takes no turn.
The Sneak Attack flag resets differently. The 5e limit is once per turn, and
a turn is anyone’s turn. resetSneak therefore gives the flag back to the
whole order at every turn boundary. A rogue that spent the dice on its own
swing can spend them again on an opportunity attack in another combatant’s
turn. refresh and resetSneak return the same participant when nothing
changes. The order array then keeps its identity, and so do the save diff and
the combatant index caches.
canSpend gates the buttons, but it does not refuse a cost. The rules have
more exceptions than this model covers, so every path that spends a cost also
gives the GM a way past the gate.
Movement has no entry in the budget. Nothing in the app moves a token by
feet, so Movement.walkSpeed is for display only.
Spending the budget
app.actions.spendBudget(id, cost, options) is the write path for every
spend, and app.actions.toggleBudget(id, cost) is the write path for the
GM’s pip override. encounterWiring.js registers both, so state.combat
keeps one writer. The cost of a spend is one of these values:
'action','bonus', or'reaction', for that part of the turn'attack', for a weapon swing'sneak', for the once-per-turn Sneak Attack flag, which costs no part of the turn'legendary', for one legendary action of a creature. The options passlegendaryActions, the count per round of the creature
The action returns false when the budget does not have the cost. With no fight running, the action reports success and writes nothing. A cast from the character sheet outside a fight uses this path.
A weapon swing spends 'attack'. rollWeaponAttack asks first and rolls no
dice on a refusal. Features.attacksPerAction reads the class features of
the attacker to find how many swings one Attack action gives.
A cast spends what its casting time names. SpellTiming.castingCost turns
the structured casting time into a cost. It reads a casting time of minutes,
of hours, or of the special kind as null, because no part of a turn pays
for those. castPlan puts the cost and a blocked flag on the plan, and
resolveCast spends the cost before the first roll. A cast is blocked when
the turn already spent that part, or when the casting time is longer than a
turn.
The attack dialog shows an “Ignore action cost” box on a turn with no swing
left. The cast dialog shows the same box for a blocked cast, with the wording
for the reason that applies. A cast that goes through on this opt-out spends
nothing, because nothing is left to take. The submit button stays disabled
until the box is ticked, through the submitRequires option of
promptModal. The dialog therefore cannot close on a swing or a cast that the
resolver then refuses. The cast dialog gates its components and armor
opt-outs in the same way.
The bonus action spell rule lives in combat/SpellRule.js. The budget keeps
two turn flags for it, bonusSpell and actionSpell, which resolveCast
sets through spendBudget after it spends the cost. castPlan asks
spellRuleBlock only when the caster holds the running turn and the cast is
not a repeat, and puts the reason on the plan as ruleBlock. The dialog then
adds an “Ignore the bonus action spell rule” box that submitRequires gates.
A cast with no target ticked is refused inside the dialog. The validate
option of promptModal runs missingTarget from app/spellTargets.js when
the GM presses Cast. When no target is picked, the dialog stays open and shows
“Pick at least one target.” above its buttons. resolveCast runs the same
check, so a caller that skips the dialog still spends no slot on nobody.
The action bar draws the budget as pips. Each cost has one pip, struck through
once spent, and the bar shows the swing count when more than one swing is
left. The pips show the budget and never gate a button. CombatantRow
includes used and attacksLeft, so the screen reads them from the same row
that it draws everything else from. combat/SpentCost.js names the spent
cost behind each button (costNote, attackNote, spellNote). The bar dims
such a button and adds the note to its tooltip and accessible name, as in
“Cast Message, action spent”. The button stays live, because its dialog
offers the GM the waiver.
Each pip is a toggle button with aria-pressed. A press calls
toggleBudget in src/app/turnActions.js, which goes through the
toggleBudget action of encounterWiring. That action spends the cost with
spend or gives it back with unspend from ActionBudget.js. Giving back
the action also drops the swings it banked, so the next Attack action banks
them again. The press writes a log line, because no other record of the
override exists.
A participant with surprised set starts the fight with the budget of
surprisedBudget: its reaction is spent, and on its own turn the action and
the bonus action are spent too. startCombat gives that budget to every
surprised participant, and refreshTurn gives it again when the pointer
lands on one. advanceTurn runs endSurprise on the participant whose
turn ends and on each participant it steps past, which drops the flag and
frees the reaction. encounterWiring also puts a Surprised chip on each
surprised combatant that ends at the end of its first turn, so the card
shows the state. The setup dialog sets the flag from its Surprised boxes.
Turn actions
src/combat/TurnActions.js lists the turn actions that are not a swing or a
cast. turnActions returns the six standard actions, each with the action
as its cost, and with cunningAction it adds Dash, Disengage, and Hide with
the bonus action as their cost. Each entry names a group, and the action
bar draws one row of buttons per group in list order. A class action joins
the bar by adding entries with its own group, with no change to the bar.
With secondWind and actionSurge, the uses left in those pools, it adds
the two fighter entries. Each of them names its poolId, and Action Surge
has a cost of null, because it spends no part of the turn.
src/app/turnActions.js is the app half. turnActionsOf reads the class
levels of a character for Cunning Action, and a creature gets the standard
actions only. takeTurnAction spends the cost through spendBudget, logs
the line from turnActionLine, and for Dodge puts a Dodging chip on the
combatant that ends at the start of its next turn. ConditionEffects.js
gives attacks against a Dodging creature disadvantage, and its
saveAdvantage entry gives the Dodging creature advantage on DEX saves. An entry with a
poolId checks that the pool has a use left before it spends anything.
Second Wind then spends the bonus action and heals through applyToTarget.
Action Surge calls the surgeBudget action of encounterWiring, which runs
ActionBudget.surge. The surged flag blocks a second surge on the same
turn. With the action spent, the action becomes free, and the swings that
Extra Attack banked stay banked. With the action free, the surge sets the
spare flag instead. The next spend of the action, through spend or
spendAttack, clears spare and leaves the action free, so the turn has
two actions in all. The action bar shows a “+1 action (Action Surge)” pip
while spare is set.
src/combat/ChannelDivinity.js adds the cleric entries: Turn Undead from
cleric level 2, and Preserve Life for a character with the feature of that
name, which the Life Domain grants at level 2. It also has the pure rules:
the Destroy Undead CR table, the Preserve Life budget and caps, and the
check of a share-out. src/app/channelDivinity.js opens the dialog first
and spends the pool and the action only on confirm. Turn Undead rolls each
save with resolveSave and puts a Turned chip on a failure, with
endsOnDamage set and spellName: 'Turn Undead' in its source, so the
end-on-damage log line names the source. Turned has noReactions in
ConditionEffects.js rather than noActions, because losesTurn would skip
the turn of a noActions holder. The ward prompts ask canReact.
Two-weapon fighting
src/combat/TwoWeapon.js is the pure half of the off-hand swing.
isLightMelee reads the kind and the light property of one weapon.
offhandWeapons returns the light melee weapons of a list, and it returns
none unless the list has two of them. canOffhand adds the budget
conditions: the Attack action is taken, and the bonus action is free.
The test reads the attacked mark and not the action flag. In 5e, the
off-hand swing is the second attack after a taken Attack action, so an action
spent on a cast does not unlock it. The app does not model which hand holds
which weapon. It offers both light melee weapons, and the GM picks the one
that the second hand swings.
combatWiring.js calls canOffhand and puts the list in the offhand field
of the action bar’s actions. The Off-hand group therefore shows only on a
turn that can take the swing. The button sends offhand: true to
weaponAttack, which spends the bonus action instead of an attack.
offhandDamageModifier sets the damage modifier of that swing. It drops a
positive ability modifier and keeps a negative one, because the rule removes
the bonus and a penalty is not a bonus.
combat/AttackTweaks.js has one table of the swings a combatant can take: the
main-hand swing, the off-hand swing, the opportunity attack, and the
legendary action. Each row
states what the swing spends, the dialog title, the text of the opt-out box,
what the log adds to the attack line, and the toast for a turn that cannot
pay. swingKind picks the row from the dialog’s answers, and canSwing asks
the budget whether the turn can pay for that row. Both functions are pure and
tested.
Reactions
src/combat/Reactions.js decides what a reaction can spend itself on.
canReact reads the reaction pip. opportunityWeapons keeps the melee
weapons of a list, because a bow cannot hit a creature that walks past.
reactionSpells keeps the spells whose casting time reads as a reaction,
through SpellTiming.castingCost.
Apart from a hit on a Shield caster (see below), the app does not detect a trigger. A 5e reaction starts from a fact that this app does not track, such as a creature leaving the reach of another. The GM sees the trigger at the table and presses the control.
The controls sit under the board card of the combatant that reacts, which is
not the combatant taking the turn. A board card is one button, and HTML does
not allow a button inside a button. combatantCard therefore returns the card
and the controls inside one .combatant-slot element. CombatScreen.reactionFor
decides who gets a control. A combatant gets one when all of these are true:
- It is not the combatant taking the turn.
- The viewer can act for it.
- It can still act.
- Its reaction is unspent.
- It has a weapon or a spell to spend the reaction on.
The swing sends reaction: true to weaponAttack, which spends the reaction
and otherwise rolls a normal swing, with the ability bonus. Its default
defender is the combatant taking the turn, because that is the combatant the
reaction interrupts. A card that the GM picked on the board replaces that
default. The cast goes to the same castSpellAction that the action bar
uses. That path already spends what the casting time names, and castPlan
finds the caster’s participant by id and not by whose turn it is.
Legendary actions
The budget of a participant has a legendary count, the legendary actions
spent since the start of its own turn. refresh sets it back to 0 when
advanceTurn lands on the creature, so the count refills once per round
at the right point. legendaryLeft(participant, max) reads what is left,
and spendLegendary spends one. buildCombatView puts legendaryLeft on
each row, which is 0 for anything but a creature.
CombatScreen.legendaryFor gives a board card a second row under the
reaction row, with the same .combatant-card__reaction styles. It uses the
tests of reactionFor, except that it asks for a legendary action left in
place of an unspent reaction. The row lists every weapon from getWeapons,
because a legendary attack can be a ranged one. A button sends
legendary: true to weaponAttack. The dialog then leaves out the
Multiattack box, and rollWeaponAttack spends 'legendary' in place of the
Attack action. AttackTweaks.legendarySwing numbers the log note from the
budget before the swing pays, as in “, legendary action 2 of 3”. The app
does not enforce the 5e timing (at the end of another creature’s turn),
because it has no event for the end of a turn apart from Next turn.
Legendary resistance
combat/LegendaryResistance.js is pure. resistancesLeft compares the
per-day count with legendaryResistanceUsed on the creature, and
spendResistance and restoreResistance write that field. The long rest
in partyWiring.js calls restoreAllResistance, which maps
restoreResistance over the creatures and returns the same array when no
creature changed.
app/legendaryResistance.js asks the GM. resistSpellSaves runs in
resolveCast after the Shield pause and before the damage reaction pause,
so Absorb Elements sees the damage that a resisted save leaves. It returns
null when no failed save belongs to a creature with a use left, and the
cast then finishes without waiting. canResist skips a target that the
spell left alone and a target of a spell with no save roll (an HP pool such
as Sleep). resistedOutcome rewrites a failed outcome as a success: half
damage for a spell that halves on a save, no condition, and no later-turn
damage. Turn Undead calls offerResistance on each failed WIS save, and its ask
option replaces the question dialog in a test. The
save that a weapon hit forces (rollHitSave) does not ask, because that
path is synchronous inside the attack roll.
The Shield pause
An attack roll that hits is a trigger that the app does see, and
src/app/shieldWard.js offers the defender its AC reaction at that point.
pendingWard looks for a spell that casts as a reaction, whose buff chip
adds AC (mods.ac), and whose castPlan the defender can pay for without
an opt-out. The defender also needs an unspent reaction, the ability to
act, no chip of that spell already, and a viewer who may act for it
(CombatView.mayActOn). wardRaise measures how far the AC would go up by
reading acOf with and without the candidate chip, so a floor such as
Barkskin’s 16 counts. Shield on a base AC of 12 under Barkskin raises the AC
from 16 to 17, and the ward offers +1 and only against a roll of 16. A spell
with no real raise is offered only when its chip blocks the attacking spell.
offerWard asks the question and casts the spell through resolveCast at
the lowest slot. It returns how far the AC of the defender went up, which it
reads with acOf before and after the cast.
rollWeaponAttack asks after the d20 rolls and before the log line, so the
line states the AC that the roll answered to in the end. resolveCast asks
through wardSpellAttack, after castSpell rolls and before
applyOutcomes writes anything. The pure CastRolls.wardedOutcome then
checks each outcome again against the raised AC. It stops a projectile that
hits automatically only when the ward chip names the spell in mods.blocks,
which is the Magic Missile rule of Shield. A target that already holds such
a chip takes none of the automatic hits, and wardSpellAttack applies that
with no question.
Both functions return a promise only while a question is open. A call with
no ward in reach finishes before it returns, so a suite that calls one
without await reads the result on the next line. Each takes an ask
option in place of confirmModal, and a test passes its own answer there.
The damage reaction pause
The damage roll of a weapon hit, a spell attack hit, or a save spell is a
second trigger that the app sees. src/app/damageWard.js offers the
defender a reaction spell whose buff chip resists a damage type in the
damage, through mods.resist or a
resistChoice pick list. pendingDamageWard uses the same gates as
pendingWard. The pure DamageWard.wardType picks the type: the one in
the hit that the spell can resist, that the defender does not resist or
ignore already, and that deals the most damage. offerDamageWard casts the
spell through resolveCast with that type as the resist-type answer, so
the chip resists it. rollWeaponAttack asks after hitDamage rolls and
before defendedDamage reads the defenses, so the new chip halves the hit.
resolveCast asks through wardSpellDamage, after the Shield pass of
wardSpellAttack and before applyOutcomes writes anything. A hit that
Shield turned into a miss deals nothing, so it asks nothing, and a defender
that spent its reaction on Shield has none left for this question. The pure
DamageWard.landingDamage reads the damage that one outcome is about to
deal before defenses: the damage of a hit, half of a splash on a miss, the
sum of the rays that hit, and the damage of a save spell, halved on a
success. A save that negates the damage and a target that the spell passes
over deal nothing. Each damaged target of a multi-target spell, such as
Burning Hands, gets its own question in target order. Each question waits
for the one before it, and wardSpellDamage looks up each target’s
reaction again when its question comes. applyOutcomes then reads each
target’s defenses with any new chip.
A fight that ends while the question is open does not stop the damage, on either path. The cast of the reaction still goes ahead on a yes, and the damage then lands the same way it lands outside a fight.
The Redirect Attack pause
A creature with redirectAttack, such as a goblin boss, can spend its
reaction when an attack targets it. It swaps places with an ally, and the
ally becomes the target. The 5e trait of a goblin boss asks for another
goblin within 5 feet, and the fight tracks no positions, so
src/app/redirectWard.js offers every ally on the creature’s side that is
not down, and the GM judges who qualifies from the notes. pendingRedirect
needs a running fight, a defender that can act and still has its reaction,
and a viewer who may act for it (mayActOn), so a Player tab never pauses
on a foe. offerRedirect opens a dialog with the allies and two buttons,
Swap places and Keep target. Keep target and Escape resolve null. A
pick spends the reaction, logs the swap, and returns the
ally.
The question comes after the attack pays and before the attack roll.
rollWeaponAttack asks once the swing has spent its budget, and then
swingAt rolls the whole swing against the ally: its AC, cover, the chip
slants, the damage, its defenses, the on-hit save, and the Shield and
damage reaction pauses. With the Multiattack box ticked, each swing asks
again, so only the first swing can redirect, because the redirect spends
the reaction. resolveCast asks through redirectSpellTargets for an
attack spell, after the cast pays and before castSpell rolls. It asks for
each target in order and swaps a redirected target for the ally. The ally
comes from the plan’s own target list when it is there, so it keeps the
fields that the spell reads. A save spell does not offer the redirect,
because it makes no attack roll.
Weapon options in the attack dialog
src/combat/AttackOptions.js decides which per-swing options a weapon
allows, so the dialog shows only the options that the rules permit for that
swing. allowsSneakAttack needs a finesse or a ranged weapon.
hasFreeHandFor needs both hand slots to be empty or to hold the weapon
itself. A shield or a second weapon therefore rules out the two-handed grip.
A creature has no equipment slots, so it always passes.
A versatile weapon with a free hand offers a two-handed grip, which rolls the
versatileDamage dice. A ranged weapon with a stated range offers a normal
and a long shot, and the long shot rolls with disadvantage. A thrown melee
weapon offers a melee swing, a thrown attack, and a long thrown attack. The
damage step checks the free hand again, because the equipment can change
while the dialog is open.
One-shot chips on an attack
A chip with mods.once (Guiding Bolt, Vicious Mockery) ends on the first
attack roll it slants (see
attack slant chips). app/weaponAttack.js
calls riderSpend.spendOnceChips after it logs the attack line, on a hit or
a miss, and after a Shield pause settles. app/spellCastResolve.js calls it
for each target of a spell attack after the cast line and before the
outcomes land. Both pass the chip lists that decided the mode, and the
function removes the used chips by name from the live roster entries of the
attacker and the target.
Cover and Sneak Attack
Cover and Sneak Attack are GM calls in the dialog before the roll. The app tracks no distance between tokens and no line of sight. No rule here can see a wall, a barrel, or where the rogue stands, so the GM answers the dialog from what they see at the table.
src/combat/Cover.js has the 5e table. Half cover adds 2 to the AC of the
target, and three-quarters cover adds 5. coverBonus reads an unknown answer
as no cover, and coverNote gives the text that the log prints. Total cover
has no entry, because a target in total cover cannot be attacked.
The cover control is a select beside the roll mode, so it is one click away
for every swing. rollWeaponAttack adds the bonus to the AC of the defender
once. The dice tray, resolveAttack, the log, and the miss toast all read
that raised AC. The log prints the raised AC and the plain AC together, such
as vs AC 12 (10 half cover +2).
Sneak Attack is a checkbox. It shows when Features.sneakAttackDice gives the
attacker dice, the weapon allows Sneak Attack, and the turn still has the
sneak flag. The label states the dice count, so the GM sees what the box is
worth. The count comes from the level in the class that granted the feature,
not from the level of the character.
The dice go in through the sneakDice option of damageParts. They are
always d6, they take the damage type of the weapon, and a critical hit doubles
them like every other damage die. The app spends the flag at the damage step,
because Sneak Attack applies only on a hit. A miss therefore leaves the flag
for the next attack of the turn.
The app checks the weapon, but not the rest of the 5e condition for Sneak Attack. That condition is advantage on the attack, or an ally next to the target. The second half needs a map distance that the app does not have, so the ticked box is the GM’s answer.
Pack Tactics, on-hit saves, Multiattack, and Surprise Attack
The attack dialog shows a Pack Tactics box for a creature with
packTactics. A ticked box sets tweaks.pack, and prepareSwing adds one
advantage slant beside the chip slants, so a disadvantage chip still cancels
it. The attack line names it, unless the GM picked a mode.
A creature weapon with an onHitSave forces a save on each hit.
rollHitSave in app/weaponAttack.js runs after the damage lands. It skips
a defender at 0 HP, rolls Checks.resolveSave with the save bonus and chips
of the defender, and logs HitSave.hitSaveLine. The line names the total and
not the bonus, so a Player tab reads the same line. A failed save goes
through applyConditionToTarget, which checks condition immunity.
A creature with multiattack gets a ticked Multiattack box while its Attack
action is unspent. weaponAttack then calls rollWeaponAttack once for each
swing, and it reads both sides again through liveAttackSides before each
swing.
A creature can mark one swing of its Multiattack with
multiattackDisadvantage, counted from 1, and CreatureAttacks.coerceWeakSwing
drops a number past the last swing. rollWeaponAttack asks
AttackTweaks.isWeakSwing before the swing pays. It reads the swing number
from the participant’s budget: an unspent Attack action makes it swing 1, and
each banked swing that the creature has used adds one. The answer goes in as
tweaks.weak, and prepareSwing adds one disadvantage slant, so an
advantage chip still cancels it. The attack line names
Multiattack disadvantage, unless the GM picked a mode. A swing with the
free-action box ticked spends no budget, so each such swing reads as swing 1.
A creature with surpriseAttack adds its dice on a hit. rollWeaponAttack
asks CreatureAttacks.surpriseDiceFor, which gives the dice only in round 1
and only for a defender whose participant still has surprised. The answer
goes in as tweaks.surprise, never from the dialog. hitDamage adds the
dice with the damage type of the weapon, doubles them on a crit, and names
them in the rider note.
Hit riders and the pact weapon
WeaponSwing.hitDamage adds the dice of the hit riders in
entities/HitRiders.js (Divine Favor on the attacker, Hunter’s Mark on the
defender), and a critical hit doubles them. Lifedrinker
adds a flat necrotic term from PactWeapon.pactDamage when the weapon is the
attacker’s pact weapon, and a crit leaves it single. The function returns a
riderNote that names each one, and hitLines puts the note after the
damage detail, as in Longsword hits Goblin Scout for 17 slashing [8,6 +3],
Hunter's Mark +1d6. The defender’s resistances then apply per damage type,
the same as for the weapon’s own dice.
Thirsting Blade works through Features.attacksPerAction, which the budget
reads as the swings of one Attack action. The weapon swing passes its weapon,
so the count is 2 only for a swing with the pact weapon. A first swing with
another weapon banks nothing, and a swing with another weapon cannot draw on
a bank that the pact weapon left, because spendAttack and
attacksAvailable use the bank only for a weapon that buys two or more
swings. The combat screen calls attacksPerAction with no weapon, so its
count of swings left is the best case.
Creature-type rules and caster slants
A save or heal spell can carry effect.typeRules (see src/types/spell.ts).
app/spellCastResolve.js stamps each target with creatureType and
conditionImmunities from the roster, and entities/SpellTypeRules.js
applies the lists in Casting.js. A skipped target reports unaffectedBy
and spends none of an HP pool, so Sleep passes over undead without losing
dice. An only list passes over a typed target outside it and lets an
untyped target through, so Hold Person on a GM creature with no type still
lands. A disadvantage type folds a disadvantage into the save mode of that
target alone, and a maxDamage type reads the damage dice at their top face.
A chip with the disadvantageVsSource mod slants the attacks of its holder
against the caster named in source.casterId. entities/SourceSlant.js
reads it, and both combat/WeaponSwing.js and the spell attack in
spellCastResolve.js fold it into the roll mode. Chill Touch writes this chip
on an undead target through the typed chip of its onHit block, and the
spell form authors that chip in ui/SpellFormTypedChip.js.
The combat view
buildCombatView(combat, resolve, viewer) in src/combat/CombatView.js is a
pure projection. It returns the round, the turn index, and one row per
participant. Each row has these fields:
- the name, side, initiative, HP, AC, and conditions
- a
defeatedflag, and anincapacitatedflag for a combatant whose chips cost it the turn - a
countedflag for a combatant that settles the outcome mayAct, which says whether this viewer can act for the combatantdeathSaves,used, andattacksLeft
The wiring layer injects the resolver (findCombatant from combatants.js),
because only the wiring layer sees every collection that an id can live in.
The order stores nothing on a row, so a rename, a disposition change, or
damage during a fight shows on the next render. The screen and the panel
derivations (sideOf, isDowned, mayActOn) all read from this one module.
The module has unit tests, and the DOM on top of it is checked visually.
mayAct is the only field that depends on the viewer. The GM can act for
anyone, foes included. A player can act only for the party character that the
tab is bound to. The screen uses this field to gate the action bar, the HP
controls, the concentration Drop control, the death-save Roll and Stabilize
controls, and the turn-end button. A player gets the turn-end button only on
their own character’s turn, and on a player tab it reads “End my turn” in
place of “Next turn”.
fightOutcome(view) returns victory once every foe row is defeated, and
defeat once every counted party row is defeated. It returns null while both
sides still have someone standing. A side with no one on it settles nothing,
so an order that the GM built with no foes is undecided. A mutual wipe reads
as a defeat, because the fate of the party outweighs the fate of the monsters.
A creature row takes its side from its disposition. A hostile creature is a
foe, and a friendly or neutral creature stands with the party. Only characters
and hostile creatures have the counted flag. A friendly or neutral creature
is therefore a bystander. Its fall settles nothing, and its survival does not
prevent the defeat of a fallen party. The side does not protect a creature,
because a hostile action can target any other creature in the fight. The party
can turn on a bystander mid-fight.
Loadout visibility
src/combat/Loadout.js defines the second viewer rule.
loadoutAccess(found, viewer, id) returns full, public, or none.
| Viewer | Own character | Other combatant on the party side | Foe |
|---|---|---|---|
| GM | full | full | full |
| Player | full | public (armor and weapons) | none |
Armor and a drawn weapon are visible across the table. The prepared spells and
remaining slots of a caster belong to that player, and a foe’s sheet is the
GM’s to reveal. buildLoadout takes the access level and never assembles what
the viewer cannot see. Code downstream therefore cannot leak that data by
drawing a field that it was given.
The layout
The screen has three columns above a turn ribbon. Below a width of 1100px, the columns stack.
The active column
The active column shows the inspected combatant, which is whoever’s turn it is by default. The column shows the name, initiative, AC, HP, condition chips, and concentration with its Drop control.
A character at 0 HP also shows its death-save tracker: three success pips,
three failure pips, and the Roll and Stabilize controls. A stable character
reads “Stable at 0 HP” and a dead character reads “Dead”, with no controls.
The block comes from ui/DeathSaveBlock.js, which the character sheet also
uses, so the screen and the sheet always describe a tracker the same way.
The HP value is exact where the viewer can act for the combatant. That is the
GM for every combatant, and a player for their own character only. Other
viewers see a coarse HP band. The GM also gets an amount field with Damage and
Heal buttons, the same controls as the Encounters panel. They apply through
applyToTarget, which is the single write path for every hit.
Under the facts is the combatant’s loadout in its full form: weapons with
their damage rolls, and a chip for each slot pool. Below the loadout is the
action bar (CombatActionBar.js). It shows one button per weapon and per
castable spell of the combatant whose turn it is, from the same weaponsOf
and spellsOf derivations that the sidebar reads. The buttons sit under an
Actions heading, with weapons first and then spells under the spellbook’s own
spell-level headings. Without the grouping, a caster with a dozen spells would
show one long run of buttons. The bar belongs to the turn and not to the
inspection, so inspecting a foe never offers its weapons to a player.
The board
The board shows the two sides as labelled groups of cards
(CombatantCard.js). Each card shows the loadout in a compact form.
LoadoutBlock.js draws both forms, so a card and the active column always
describe a combatant the same way. The host trims each card to what the viewer
can see.
Each card is a real <button> that picks the target. A click selects the card
(aria-pressed), and a second click releases it. The selected id pre-fills
the defender field of the attack dialog. It also pre-fills the target field of
the cast dialog, whichever picker the spell built: a single select, a
multiselect, or the projectile allocation grid (through prefillTarget in
spellTargets.js). The six situational fields of the attack dialog sit behind
a collapsed disclosure (the advanced field flag of promptModal). The common
flow is therefore a click on the card, a click on the weapon, and Enter.
The log column
The log column shows the travelogue entries of the combat and roll kinds,
newest first. It shows only entries logged since this fight’s setup opened,
so the column does not replay every fight in the campaign. The setup takes a
timestamp when its dialog opens, and startCombat stores it as
CombatState.startedAt. The “Initiative rolled” line is logged inside the
dialog, so it falls after that timestamp. The column shares the row builder
of TravelogPanel.js. logEvent stamps each entry with the in-game clock
and, during a fight, the round. The column passes withRound to entryItem,
so a fight line reads “Round 2” there and “Day 1, Dusk, round 2” in the
travelogue.
The travelogue keeps its newest 200 entries (TRAVELOG_LIMIT). A fight of five
rounds with ten combatants can log more than that. logEvent therefore passes
the running fight’s startedAt to appendEntry, which then trims only the
entries older than the fight. The list can grow past 200 during a fight, up to
TRAVELOG_FIGHT_LIMIT (1,000). The first line logged after the fight ends
trims the list back to 200. The column keeps as many rows as the larger limit.
GM-only lines
A log line that names what the Player view hides elsewhere is GM-only. The
caller passes a third argument to logEvent: { gm: true } for a line that
a Player tab never shows, or { player: '...' } for a line with a
player-safe version. The entry keeps gm: true and the player text beside
message. log/LogVisibility.js builds the list that each role reads. The
GM reads every message. A Player tab reads the player text of a GM-only
entry, or does not see the entry. The ids stay the same, so both log lists
keep their append-only updates. A role change rebuilds both lists, because
the two roles read different lines.
These lines are GM-only:
- the total and the rolls of an HP pool (Sleep, Color Spray), which a Player tab reads as the dice alone
- the reason on each target of an HP pool or an HP limit (“within the pool”, “over 100 HP”, “150 HP or fewer”), which a Player tab reads with no reason
- the save bonus of a creature on a save spell or an on-hit save, which a Player tab reads as the ability and the total
- the HP readout of a creature after a damage or a heal from the amount field, which a Player tab reads without the readout
combat/SaveLines.js builds the GM and player versions of the save lines.
A party character’s lines stay open, because the sheet shows the same
numbers. An AC in an attack line stays open too, because the combatant cards
show a foe’s AC to every viewer.
The dice tray sits under the log. The app has one tray. While combat mode is
active, the screen moves the whole #dice-tray-container card into the column
with appendChild, and it puts the card back in the play dock on exit. Moving
the element keeps the handle of diceWiring.js valid, because the app mounts
the tray once and never looks it up again. The right column is position:
sticky and no taller than the screen, so the tray stays in view while the
GM scrolls the board or a long active column. The log list scrolls inside
the height that the tray leaves. Below 1100px the columns stack, and the
column scrolls with the page.
Above the log, buildTabs adds a Log tab and a Map tab. The Map tab holds
ui/CombatMap.js, a canvas that draws the party node with the same
MapRenderer as the main map. map/FightFrame.js works out the tile size
and the offsets that center a window of nine by nine tiles on the party
tile. The canvas has no pan, zoom, or click handling. combatWiring.js
passes getMapView, which reads the party position from partyTracker.
combat/FightMarkers.js marks the tile of each hostile creature of the
order that still stands, which is the rule of the Play map. A companion, a
summoned ally, or a defeated foe gets no marker. The view also passes the
marker range of the Play map (twice the reveal radius of partyTracker)
and sets fogDim by seesThroughFog in src/view/ViewRole.js, the
same rule as the Play map. So the GM sees the terrain under a
see-through fog, and a Player tab gets opaque fog and shows nothing that
its Play map hides. The screen redraws the map on each render while the
Map tab shows, and when the GM opens the tab.
The turn ribbon
The turn ribbon runs above the columns. It shows one chip per participant, in
order, with the name and initiative. A long name ends in an ellipsis, and the
number that tells two foes of one name apart stays whole (chipName). The
current turn is ringed and marked
aria-current. An icon marks each foe, so the side does not depend on color
alone, and a defeated chip is struck through. A click on a chip inspects that
combatant without advancing the turn.
The round counter and the turn controls sit beside the ribbon:
- Back to map, for everyone
- the turn-end button, for whoever can take the current turn
- End combat, for the GM only
Starting a fight
Only the GM starts a fight, from the Start combat button in the Active tab of
the Encounters panel, or from the Set up combat button of the arrival modal.
Both read one list, hostileGroup in src/entities/CreatureMap.js. It keeps
the undefeated hostile creatures of encounterGroup, which is every creature
in the party’s node within ENCOUNTER_RADIUS (one) grid steps of the party’s
tile. A diagonal step counts as one, so the group covers the party’s tile and
the eight tiles around it. A GM who stages two bandits on neighbouring tiles
therefore meets both in one fight. canStartCombat in encounterPanels.js
gates the button on that list. The Active tab and the difficulty hint list
the same creatures, so the panel switches to that tab whenever the button can
show.
The setup roster from combatRoster is the whole party, the creatures of
hostileGroup, and any friendly or neutral creature on the party’s own tile.
A defeated hostile creature stays staged but does not join a new fight, and a
bystander on the tile joins in any condition. Hostile creatures line up as
foes, and friendly and neutral creatures line up with the party. A friendly or
neutral creature alone does not make an encounter. The GM who wants to fight
one sets its disposition to hostile first.
nearbyFoes in combat/CombatRoster.js lists the undefeated hostiles
within nearbyRadius that stand outside the encounter group, each with its
tile and its straight-line distance in tiles. The setup dialog shows them
under “Add nearby foes” with a Join box, and only the ticked ones join the
roster for Roll initiative and Start. nearbyGroups splits the list by
tile, and a tile with two or more foes gets an “Add the whole group” box
that ticks each Join box of the tile. groupTitle names the creatures
of the group in that box (“Gray Wolf x2, Goblin”), so a screen reader
hears which creatures it adds. During a fight, the Nearby tab gives each hostile
row outside the order an “Add to fight” button. encounterPanels.js rolls
initiative for it and calls app.actions.addCombatant. The Nearby list
repaints on the set of joinable ids through its dependsOn, so the button
shows when a fight starts and goes when it ends. The joined row leaves the
list, so EncounterPanel.js moves keyboard focus to the “Add to fight”
button now at the same place, or to the last one. With no button left,
focus goes to the selected tab.
Only a hostile creature is a threat, and only a threat opens the arrival modal
when the party steps next to it or onto its tile. A friendly or neutral
creature opens nothing. The NPCs panel lists it, and a step onto its tile logs
a meeting. arrivalAlert in src/combat/Arrival.js writes the text of the
modal from the name, currentHP, and maxHP of each creature in
hostileGroup. The GM sees exact HP, and a player sees an HP band. With no
fight running, the GM’s modal has Set up combat and Not now buttons, and Set
up combat opens the setup dialog at once. A player’s modal, and a GM’s modal
for one character’s token away from the party, has one Continue button,
because the setup reads the party’s shared position.
Rolling initiative
Initiative is a Dexterity check, so src/combat/InitiativeRoll.js rolls it as
one. initiativeSlant asks ConditionEffects.rollMode for the mode that the
roller’s chips give a check. It adds the disadvantage of armor the roller is
not trained for, and it reads the exhaustion penalty. rollInitiative throws
the d20s, keeps the higher or the lower one, and adds the DEX modifier that
the roster stored on the participant.
One press of Roll initiative fills the whole column, so this roll does not go
through the dice tray, which shows one roll at a time. The roll returns a note
that names the dropped die and each reason, and the travelogue line includes
it. encounterWiring.js resolves the participant id to its entity with
findCombatant. An id that resolves to nothing rolls a plain d20 instead of
failing. The GM can edit every value by hand before Start.
Ending a fight
A fight ends when the GM presses End combat, or when no creature of the fight is left near the party. The defeat of the last enemy does not end the fight. An automatic end on the last kill would close the screen mid-swing, and it would take the log and the board away before the party could heal.
The End combat control
endCombat calls confirmFightEnd in app/combatEnd.js first. The
button sits next to Next turn, so one stray click would otherwise drop a
live fight. When hostile creatures still stand, the XP dialog opens with a
line that counts them and a Back to the fight button, so it serves as the
question. opensXPDialog tells confirmFightEnd when that dialog opens.
Only a fight with standing foes and no living character to earn XP gets a
separate confirm. A lost fight closes with no question.
After a victory, askFightXP offers the experience points of the defeated
foes while the fight still runs. FightEnd.fightEnd adds up the crXP
value of each defeated hostile creature, and a foe with no challenge rating
is worth nothing. The share goes to every character in the order who is
still alive, a dying one included, split evenly and rounded down. The GM can
change the amount. When the GM picks Back to the fight, endCombat
returns before setCombat(null), so the fight and its foes stay as they
are. Otherwise the fight closes and applyFightXP gives each character the
amount through addXP, so a new level becomes pending in the usual way.
The fight keeps running while the dialog is open, and a Player tab can take
a turn in that time. So endCombat reads fightSummary again before it
closes the fight, and applyFightXP uses that summary. A fate lands only
on a foe that still stands, and the XP goes only to a character still
alive. The amount stays what the GM typed.
The automatic end
syncCombatLocation ends a fight when the party walks away from it or the last
creature in it is deleted. The party-move paths and commitCreatures call
this action. main.js also calls it once after every module is wired, so a
save whose fight has no creature left near the party’s tile loads with the
fight ended. It runs there and not inside wireEncounters, because it logs
through logEvent and leaves combat mode through setMode, which
wireStory and wireSessionControls register later. The plain panel
refresh never calls it, because that refresh also runs from the rehydrate
loop. There, a state write would conflict with the save that the tab just
took from another tab.
The check is fightInReach in combat/CombatRoster.js. It keeps the
fight while any creature of the encounter group is in the running order, or
while any hostile in the order stands within nearbyRadius, four times the
reveal radius. That radius is the one of the Nearby tab and of the “Add
nearby foes” list, so a foe that joined from there does not end the fight at
once. Both checks count defeated creatures, because a combatant at 0 HP is a
turn in the fight and not the end of it. A bystander counts only inside the
encounter group. A walk away from the fight ends it, and so does the deletion of the
last creature in it, but a kill does not.
The outcome banner
Once fightOutcome settles, the screen shows a banner at the bottom center
of the window. It floats with position: fixed, so the ribbon and the
columns do not move when it appears. The
banner states that the party is victorious or defeated. It tells the GM that
combat stays open until the GM ends it. At that point, End combat takes the
primary emphasis from the turn-end button. Turns still advance, so the party
can use a round to heal before the GM ends the fight.
The banner is a persistent role="status" node. The app does not rebuild the
node on every render, because a new node would announce the outcome again on
every HP edit. The app also unhides the node before it writes the text,
because a screen reader does not read a status region that is hidden when the
text changes.
Refreshing the screen
combatWiring.js registers app.views.combatScreen. It mounts before
wireEncounters, so the view exists when the fight’s refresh paths run.
The registered update function skips the rebuild while the tab shows another
mode during a running fight, because nothing on the screen is visible then.
The switch back into combat mode is itself a refresh path, so the first visible
frame is always new. When the fight has ended, update still rebuilds, which
empties the screen of the last fight’s DOM.
The deferred rebuild
update asks src/combat/RefreshScheduler.js for a refresh. The first request
in a synchronous burst schedules one run in a microtask. Every later request in
the burst does nothing.
One weapon attack reaches the view four or five times: the budget spend, the
attack line, the damage line, the target write, and the defeat line. Without
the scheduler, each of those calls would rebuild the whole screen. With it,
they cost one rebuild, and the browser paints no frame between them. The
scheduler takes its schedule function as an argument, so a test can run the
flush by hand. The dice tray still moves at once, inside update, because the
mode’s CSS has already changed by then.
The log column does not rebuild. CombatLog.js keeps the id of the newest row
it drew. On each update, it asks entriesAfter for the entries logged since
then and adds only those at the top. It rebuilds only when that id has left the
log, which means that the log was cleared or a new fight began.
TravelogPanel.js renders the same way. The list is a role="log" live
region, and a screen reader reads only the added rows. A list rebuilt whole
would read every row again, or read nothing.
Refresh paths
Each of these paths calls the registered update:
- The initiative-panel wrapper.
encounterWiring.jswrapsviews.initiativePanel.update()and refreshes the combat screen inside it. Every call site that the sidebar card already has (party moves, role switches, the rehydrate loop,commitCreatures) reaches the screen with no extra code. - Combatant writes. The character
storethatfindCombatantuses updates the screen directly. A creature write reaches the screen throughcommitCreatures. - The log.
logEventrefreshes the screen. A line that changes no combatant, such as a missed attack or a plain tray roll, would otherwise not reach the log column. - Mode changes.
sessionControls.jsupdates the screen on every mode switch. The registeredupdatefirst syncs the dock of the tray withstate.mode, so the tray moves on every entry and exit: the automatic entry, the automatic exit, the header’s Play button, the sidebar’s Open combat control, or a reload that resumes a fight.
A reload with a fight running enters combat mode again from main.js, after
wireSessionControls has registered setMode, for either role. A player takes
their turn on that screen too, and anyone can leave with Back to map to watch
the map.
Cross-tab rehydrate adopts campaign state in place and leaves mode out of
its synced keys, so a display tab in the Player role does not follow the GM
tab into Build mode. combat is in the synced keys, and the rehydrate refresh
loop includes the initiative panel, whose wrapper refreshes the screen.
A fight that starts or ends in another tab is the one case that moves a
follower tab’s mode. followerMode in view/CombatMode.js decides the move,
and app/externalSaves.js acts on it. A tab in Play mode enters combat mode
when a fight starts, and a tab in combat mode returns to Play when the fight
ends. A tab in Build or Library mode stays where it is, because a fight is not
a reason to discard what that tab has open.
Accessibility
A visually hidden aria-live="polite" region announces each turn, for example
“Round 2: Mirelle’s turn.” The region is keyed on the round and the combatant
id, so an HP edit or another refresh announces nothing extra. When the
fight has an outcome, the region reads “The fight is over.” once and skips
the turn announcement, and it is emptied when the fight view goes away. Each
action pip toggle has the fixed name “
The ribbon and the board are one tab stop each. A roving tabindex anchors on the chip of the current turn and on the selected card, and the arrow keys move focus with wraparound. The keydown listeners attach once, at mount, to the persistent containers. They find the buttons on each keypress, because every render replaces the buttons.
A rebuild replaces every control, so without help, focus would fall to the page
body after each rebuild. src/combat/FocusRestore.js decides where focus goes.
Before the rebuild, the screen names the focused control: a chip or a card by
its combatant id, and any other control by its accessible label or its text.
After the rebuild, the control with the same name takes focus again, so the
Damage button keeps focus through the HP edit that it made.
When that control is gone or disabled, focus goes to the chip of the current
turn, and then to the round heading, which is a persistent h2 with
tabindex="-1". Focus on an element that is still in the document, such as the
docked dice tray, stays where it is. The first frame of a fight moves focus onto
the screen the same way, because the Start control that opened the fight is
gone by then. Back to map and End combat move focus to the map canvas.
The attack and cast dialogs need no extra help. A dialog opens before any write, so the button that opened it is still in the document when the dialog closes, and that button takes focus back. The deferred rebuild then runs and moves focus to the new button with the same name.
The sidebar card
InitiativePanel.js is a status card. It shows only while a fight is running.
It has one line, for example “Round 3, Mirelle’s turn”, which it resolves
through describe so that a rename shows. It also has an Open combat control.
The wrapper around its update function refreshes the combat screen, as
described in Refresh paths.