4 PCs vs whatever the room rosters, the real map, real stat blocks, real dice.
Click the gold-ringed token → pick a verb → click a target. That is the whole thing.
Move arms a drag: the reachable set appears in cool blue, drag anywhere to preview the curved walk
path in amber, release and it walks with the right locomotion clip. Attack asks for a target: hostiles
get a red ring (solid = in reach, dashed = it will walk in first). The token winds up, the d20 lands on
screen and stops, then the swing plays in the damage-typed variant, the target takes the hit, particles and
ground residue land and the sound fires. A killing blow either plays an ordinary death (the token stays,
darkened by the shared status-tint.js) or, if the kill qualifies, a finisher.
Cast opens the caster's real, class-filtered list — cantrips included — with each spell's
range, whether it is an attack roll or a save, and its dice. Pick one and the board arms: a dashed ring shows
the spell's range, an area spell paints its template under the cursor and rings everyone it would catch,
allies in green, before you commit. An attack spell goes through the same roll a sword swing does,
so it gets advantage, cover and resistance for free; a save spell rolls each target's save and halves on a
success where the spell says so. Right-click or Esc cancels without spending the Action.
Creature turns are still not AI, but they now move. Five stated rules on top of
walk toward the nearest hostile and hit it: stand up when Prone; run at 30% HP (and take the
opportunity attack, because it needs its Action to shoot); step out of melee to shoot when its ranged
attack is at least as good as its melee one; Shove the hard target Prone when an ally is already on it,
which hands that ally advantage; and Help an engaged ally when it has nothing it can reach. Each names
itself in the intent card on the right, with its reason, before it is committed.
Controls: ⏸ freezes everything mid-beat, including between the dice landing and the damage applying;
▸1 / ▸10 step exact 1/60 s frames. Rig the next roll forces a crit, a kill or a specific finisher tier on
the very next attack rather than making you wait for one. The live readout tracks the two things that can
actually break under load — decals against their 130 cap and voices against their cap of 8.
⚔ Extra Attack is in (punch C9). One Attack action now yields as many attack rolls as the
SRD class table says — a level-5 Fighter swings twice, level 11 three times, level 20 four times, all read
live out of content/srd/classes.json rather than typed into the code. It is not a second Action:
Dash, Dodge and Cast stay locked after the first swing, and the remaining swings are freely re-targetable,
so dropping one enemy lets you turn the rest of the flurry on the next (BG3's reading). Sneak Attack is still
once per turn, not once per swing.
⚠ Two honest gaps: misses have nothing to look at, and round 1 is mostly everyone walking. Both are
in docs/BATTLE.md, with what they would cost.
100%
LOG—
Dice — engine/dice-ui.js CombatRollStrip
The dice track's own widget, handed combat-resolve.js's result verbatim.
The board also flashes the same numbers over the two combatants, so the roll is readable without
looking away from the fight.
When the fight ends — engine/victory.js (P20 · P21 · P22)
There is no button for this and there must not be one. The last hostile drops,
the gold VICTORY card comes up with a four-line summary (rounds, kills, damage dealt and taken,
resources spent, what was looted), the victory sting fires on the same frame as the card, and
the board hands itself back to adventure mode. Combat begins and ends on its own; the mode
follows it.
Looting is automatic. Every defeated hostile is searched and what it carried
goes into the party's packs — no clean-up screen, no extra click. Check a character sheet's pack
afterwards; the log names every item and who took it.
Adventure mode: click the map to walk. No turn, no initiative, and
nothing is charged — no action, no bonus action, no movement budget, because there are no
rounds out here. Click another party token to take control of it. Clicking under the bridge keeps
you under it: a bare click carries no verb, so it resolves to WALK and keeps your level
(punch G21), and the log prints the tier it predicted next to the tier you arrived on.
⚠ Not built, and named rather than skipped: the DM half of G22 —
click a non-party token to take control of it and drive it through the action menu. Clicking a
monster in adventure mode says so in the log instead of doing nothing. ⚠ The room music is a
placeholder mix, not a placeholder feature: the transition is real (a different track id at
a lower level, through fx.js's own crossfade), but no dedicated exploration score has been
generated — the explore beds share the combat beds' files for now, which
content/music.json states in each track's _pending note.
Board control
PT6 — this pass's playtest fixes
PT6-02 — the reading, in one line: a target that FAILS the DC check has its
lingering particle effect cleared (it plays its beat, then stops — 1.2 s).
A target that makes the save keeps whatever it was already wearing.
Say the word if you meant the reverse and it flips.
Either way the save now says which it was, on the target: green ring closing
IN = SAVED, red ring opening OUT = FAILED.
PT6-06 — the miss. A miss now gets a MISS! wordmark over the
token, in the approved MANGA register and deliberately detuned against the crit on every axis:
the crit owns the screen (92 px, gold, leans right, slams in, shakes the camera), the
miss owns the token (44 px, ash and slate, leans left, sags and drops, no shake,
no hit-stop). The old tactical chip is unchanged underneath it — the wordmark is the read, the
chip is the evidence.
PT6-03 — the grid. It was never drawn three times; measured at
1.00 stroke per frame. It was the wrong grid: hardcoded 16×9 at 120 px while this
room's board is 37×21 at 51.892 px, so each drawn square held 5.4 real squares — and
every line was split across two pixels at half alpha, which is what made it look furry. It is
now the board's own grid, one pixel per line, at an alpha scaled so the finer lattice does not
put more white on a dark room. The lighting is untouched.
PT6-04 — the spell description, from the bar too. Open
✦ Spells on the action bar and hover a row: you get the same tooltip the Ring
shows, because it is the same one — engine/radial.js's verbTip,
fed by the same ringSpellItem() the Ring's wedges read. An unavailable spell's tip
goes red and carries the engine's refusal reason instead of the description, exactly as the Ring
already does.
⚠ The tap equivalent, designed in the same pass, because there is no hover on a finger
and the build is tablet-first: the first tap on a spell row explains it and
casts nothing; the second tap on the same row casts. That is the board's own
"tap that cell again to commit" idiom, and it fails safe — a mis-tap that explains a
spell beats a mis-tap that spends a 3rd-level slot. A trackpad on the same tablet still gets
one-click casting: the branch is on the pointer, not on the device.
PT6-01 — the screen is quiet now, and the three states stayed.
The verbose block over a token (DIS · SHOOTING UP +2 AC · the reason sentence) is
hover-only: it appears over the token under the cursor and nowhere else. What replaces
it everywhere is a persistent three-icon row for the states you named —
COVER (cold blue, a crenellated parapet), HIDDEN (violet, a crescent),
HELD (amber, a padlock). Wide-flat / round / tall-arched: three different
silhouettes, so the set still reads with the colour taken away. Each icon is
24 px against a 72.2 px token disc, drawn as vector paths — no raster asset and
no API.
⚠ The tap equivalent: on glass the last token you tapped is the one showing
its block, and it stays until you tap another. That is no new gesture — the board already
hit-tests a token on every tap. The icons are never gated by any of this: they are on
for every body on both input methods, because "clean up the screen" must not cost you the
at-a-glance read you asked for.
loading…
LT1 — darkness, darkvision & torches
loading…
A torch is world light: it lights the square for everybody and
lightAt() reads it. Darkvision is viewer vision: the SRD says a creature
treats darkness as dim light for itself — it lights nothing for anyone else. That is why
the radial nimbus and the vision cone are two shapes on two code paths, and why the cone is
cold and the torch is warm.
LT2 — the cone follows your mouse
Move the pointer over the board. The tracking
character's darkvision cone sweeps to follow it.
loading…
⚠ It is a viewport, not a sense. Where you point changes what the screen
shows you and nothing else: lightAt() never reads a cone, and
seenBy() uses darkvision as a radius — which is the SRD, darkvision is a
sphere. Swinging the mouse cannot swing a DC, move a Perception check or reveal a target you
could not already target. tools/lt1-shot.mjs sweeps the aim through 360° and
asserts every rules answer is unchanged.
Rig the next roll
none — honest dice
The forced tiers bypass the maths, not the presentation:
engine/finisher.js runs the real tier-N production. Everything else rigs only the die,
so the gate still evaluates for real.
Live numbers
⚠ Frame cost is the time inside this page's own step body, not achieved FPS.
rAF is unreliable in a shared review pane (a previous track measured 1.4 fps there); a clean FPS figure
needs an uncontended tab and is W3b's job.
⚠ Contract mismatches found live
None yet this session.
Eleven subsystems that had only ever run alone. Anything this page can detect
automatically — two modules disagreeing about a number — is logged here rather than buried.
Nothing is patched in another track's file; see docs/BATTLE.md.
🗣 Party emotes — the bark log
Nothing offered yet.
Every offer, spoken or not, with the reason it was refused —
EXODUS's own barkStats discipline (hovervan.js), because
"why did nobody say anything" has to be answerable from the screen.
A system that fires on everything is as wrong as one that fires on nothing:docs/EMOTES.md §4 wants 20-35% of eligible events (revives 60-80%),
a 12-20 s per-speaker cooldown and a 5 s global party lockout so the party never
talks over itself. Selection, cooldowns and the lockout are engine/emotes.js;
which combat event means which emote event is engine/emote-triggers.js, and
tools/emote-fight-log.mjs drives that same module over a full headless fight.
⚠ PARTY_BUFFED_ME and PARTY_SAVES_ME are wired and permanently silent — there is no
buff system and no intercept concept in this codebase, and a faked trigger to make the
demo look fuller is the defect, not the fix.
Dice — last roll
No roll yet.
Intent — last committed
Nothing committed yet.
Every turn — a player's click and the creature policy alike — produces a named intent
object first, and a separate commitIntent() resolves it. That seam is where DM review and
host-authoritative replication attach (docs/DM-MODE.md §2).
5E rules — engine/combat-5e.js
No turn yet.
Every attack roll's advantage/disadvantage, its range band and its cover are decided
before the die, in one place, and printed here and in the log with the reason. 5E's
"one of each cancels" is applied, not a net count.
Roster, factions, conditions & resistances
⚑ Faction is mutable runtime state, not a property of where the stat block came from.
The arrow button flips one combatant's faction field — nothing else. Targeting, the creature
policy, the ring's "target in reach" predicate and the win check all read that field live, so a defection
takes effect on the next question asked. It is here as proof the field is authoritative, not as DM UI.