Jonathan Mellette Contact

Solutions architecture · In use · v1.8.0 · Private

Encounter

A two-screen combat tracker for in-person tabletop games: the DM runs the fight in one browser window while a second window, on a monitor facing the players, mirrors the turn order live. There is no backend: the sync is the browser’s own storage events, and the app code makes no network calls.

Status
In use at an in-person table; unreleased.
Version
1.8.0, tagged 9 Oct 2026; 14 tagged releases, v1.1.0 through v1.8.0 (8 minor, 6 patch)
Licence
None yet: there is no LICENSE file, though the README says MIT.
Runs on
A local server opened in two browser windows on one machine. The Tauri desktop shell is shelved.
Stack
React 19.2 · TypeScript 5.9 (strict) · Vite 8 · hand-written CSS themes · localStorage + storage events
Links
None; the repository is private.

Measured · how

The constraint

One table, two audiences: the DM needs every number, and the players need to know whose turn it is without seeing anything the DM is hiding.

At a glance

tagged releases, v1.1.0 to v1.8.0
14
network calls in app code
0
runtime dependencies; all 15 direct dependencies pinned exact and installed with lifecycle scripts disabled
2
strict-typecheck errors across 2,420 lines of TypeScript
0
How these were measured
  • 14: git tag -l 'v*' in the project’s repository on 12 Oct 2026 lists v1.1.0 through v1.8.0. The 1.0.0 initial build of 31 Mar 2026 was never tagged.
  • 0: grep -rnE 'fetch[(]|XMLHttpRequest|WebSocket|EventSource|sendBeacon' src finds no matches, and the app’s index.html loads only local files.
  • 2: package.json lists react and react-dom 19.2.4 as runtime dependencies beside 13 development ones, and none of the 15 uses a ^ or ~ range. The setup script, and the README’s manual install line, run npm install --ignore-scripts, so no package’s install script runs during setup.
  • 0: tsc in strict mode against the app config (src: 18 .ts/.tsx files, 2,396 lines) and the node config (vite.config.ts, 24 lines), with build info sent to a scratch file, exits 0 for both. The project as a whole is 4,745 lines by wc -l over the 32 committed first-party .ts, .tsx, .css and .sh files: 2,396 TypeScript, 2,112 CSS, 213 shell, 24 config.
Encounter’s player display in round 3: eleven combatant portraits in a row with the active one, Brother Ash, enlarged at the centre. Player characters carry an AC shield and an HP bar, monsters show only their state, a dead goblin is greyed with a skull, and a strip of thirteen round portraits along the top shows every turn the players are allowed to see.
Fig. 1The player-facing display, built for a 32:9 ultrawide (cropped to the centre on small screens). Invented demo roster; placeholder portraits.

The short version

  1. Two windows, one origin, zero backend. Each window rebuilds the turn order from the same saved data with the same pure function, and the browser’s own storage events keep the player display in step.
  2. Dropped the desktop packaging once the product changed shape. A Tauri wrapper went in on 6 July 2026; a day later the second screen arrived and a local server plus a browser took over. The shelved shell is 25 lines of Rust.
  3. Designed a rules-module contract before its second module existed. One pure hook reshapes the list of turns; its first module gives every monster and NPC a second turn at initiative minus 10.

Case 1 of 5

One app, two screens, no backend

The player monitor is a second, read-only window on the same origin, kept live by the browser’s own storage events.

Encounter serves both audiences from one app with two views: the DM’s control window and a read-only tracker on a monitor facing the players.

The windows share an origin, so they share localStorage. The tracker listens for the storage event, which the browser fires in every other window when one writes, and re-reads its keys on focus. A turn advance reaches the player monitor at once, with no server, socket or polling. Neither window hands derived state to the other: each rebuilds the turn order from the same saved data with the same pure function.

What the players see is curated in code. Hidden combatants are filtered out before rendering, and no hit-point number reaches their screen. Monsters show one of four states (healthy, bloodied, a pulsing critical, dead), while player characters get an AC shield and an HP bar, and conditions appear as labelled icons. The dock shows at most 11 portraits at three sizes, designed for a 2560×720 ultrawide and scaled down to fit a smaller screen’s long axis.

The DM’s combat window in the dark theme: fifteen rows of turn slots with initiative, name, armour class, condition icons, hit points with a bar, and damage, heal, undo and temporary-HP controls on each row. The active row is highlighted, a dead goblin is struck through, a hidden dire wolf is dimmed, and five rows carry a 2ND badge.
Fig. 2The DM’s window in the dark theme, with the same invented roster: 10 combatants in 15 turn slots. The five 2ND rows are extra turns from the rules module in Case 3; the struck-through goblin is dead and the dimmed wolf is hidden from the players.
How we know

Read from the committed source on 12 Oct 2026. The tracker’s shared-state hook subscribes to the window’s storage and focus events (src/components/TrackerView.tsx). buildTurnOrder in src/modules.ts is documented as pure, and both App.tsx and TrackerView.tsx call it. The same TrackerView file filters hidden slots before rendering, sets the bloodied and critical thresholds at 50% and 10% of maximum HP, and sizes the dock at 470, 300 and 210 px with up to five portraits a side, scaled down (never up) to fit the long axis.

Case 2 of 5

Dropping the desktop packaging once the product changed shape

The desktop wrapper lasted a day: the player display is a second window kept live by storage events, and an ordinary browser on a local server already provides that.

  1. Version 1.0.0, the initial build, never tagged.
  2. A Tauri 2 desktop wrapper goes in, and installable builds are produced.
  3. v1.1.0 adds the player-facing second screen, and the runtime becomes a local server plus a browser.

The second screen made the wrapper a liability. The player display lives at its own URL path, and a packaged build loads its files without an HTTP server, so that path had nothing to answer it short of changing how the app picks its view. The macOS build had a second problem: its embedded webview could not be relied on to deliver storage events between windows, and those events are what keep the player display live. Two windows of an ordinary browser, pointed at a local server, have neither problem.

The wrapper was shelved, not deleted: 25 lines of Rust, still version-synced, and the entry point still accepts ?tracker as a fallback for packaged builds.

The new model got its own tooling. A server manager’s stop command kills only processes listening on the app’s port, after checking that each process ID is a plain integer. A one-command game-night setup installs dependencies with lifecycle scripts disabled, starts the server and opens both views.

How we know

Dates are commit and tag dates from git log --date=short and the v1.1.0 tag. The ?tracker fallback is in src/main.tsx. The stop command in encounter.sh lists only listening sockets and matches each process ID against a digits-only pattern before killing it, and --ignore-scripts is in setup.sh, which the game-night script calls. 25 is wc -l over the tracked Rust files in src-tauri. Checked 12 Oct 2026.

Case 3 of 5

A rules-module contract designed before there was a second module

Creatures hold the state, turns are slots, and opt-in rules reshape the slots through one pure hook.

Creatures and turns are kept apart. A creature holds the state: hit points, armour class, buffs and conditions. A turn slot is one moment in the order the turn marker walks.

Opt-in rules modules reshape the slot list through one hook: data in, data out, and by design rule a module never touches storage or app internals. A stable sort by initiative runs afterwards, so a tie order the DM set by dragging survives. The first module, Monsters act twice, gives every non-player creature a second turn at its initiative minus 10, to keep fights with a big party moving. Each turn gets its own HP controls, and all of them drive the one shared creature.

The hook is written as a public contract that outside modules could target later without a rewrite, and the 17-entry condition registry (the 15 standard 5e conditions plus Concentration and Hunter’s Mark) follows the same shape. The combat rules get the same care: Shield’s +5 AC drops when the turn comes back to its caster, temporary HP absorbs damage first, and undo restores both pools.

How we know

src/modules.ts defines the slot and module types, a registry holding one module, and the engine’s stable sort by initiative. In src/components/EncounterView.tsx the damage and temp-HP inputs are keyed by slot, Shield’s +5 comes off when the active turn reaches a shielded creature, damage drains temp HP first, and undo restores both pools. grep -c counts 17 entries in src/conditions.ts. The turn-slot model and module hook shipped in v1.3.0 and slot-keyed HP controls in v1.4.0, both tagged 22 Jul 2026. Temp HP arrived in v1.1.0, AC buffs with Shield expiry in v1.2.0, and the condition registry in v1.6.0. Read 12 Oct 2026.

Case 4 of 5

A combatant-dropping bug fixed at the root, with a safety net

Ids that restarted at 1 on every reload were colliding with saved ones. v1.5.0 fixed the source and guarded the boundary.

During a live game, combatants vanished the moment the encounter started. Ids came from a module-level counter that restarted at 1 on every page reload, while the roster persisted in localStorage, so a new entry could take an id a saved one already held. Downstream, a Map keyed by id kept only the last entry per key, and the duplicates disappeared without an error.

The fix, released in v1.5.0 on 26 July 2026, has two layers. Ids are now minted with crypto.randomUUID() at the source, and starting an encounter regenerates any duplicate id, so even data saved under the old scheme can’t drop a combatant.

The honest coda: the same resettable counter survives in the Party and NPC builders. It drops no one, but after a reload a new NPC template can reuse an old id. It is unfixed in v1.8.0 and listed in the honest notes.

- let nextId = 1
- function genId() { return `combatant-${nextId++}` }
+ function genId() { return crypto.randomUUID() }
Fig. 3The initiative list’s id source, v1.4.0 against v1.5.0.
How we know

git show v1.4.0:src/components/InitiativeTracker.tsx shows the counter, and the same file mints UUIDs from v1.5.0 on. The encounter-start guard is buildEncounter in src/App.tsx. The v1.5.0 tag and the changelog’s Fixed entry date the fix to 26 Jul 2026. The leftover counters sit at the top of src/components/PartyBuilder.tsx and NpcBuilder.tsx. Read 12 Oct 2026.

Case 5 of 5

Portrait mode by turning the dock on its side

v1.8.0 runs the player display on a monitor stood on end, in a change of 88 lines added and 11 removed across 2 files.

The change stayed small because of an earlier choice. The dock was already sized from one long-axis extent of square portraits, so the only new question was which axis is long.

In portrait the dock becomes a column instead of a row and fits the height instead of the width. The order strip turns into a rail down the right edge, and the round label moves into a reserved header band so nothing slides under it. Landscape is unchanged.

How we know

git diff --stat v1.7.0 v1.8.0 -- src reports 2 files changed, 88 insertions and 11 deletions. The orientation hook and the single long-axis calculation are in src/components/TrackerView.tsx, paired with a portrait media block in TrackerView.css. Tagged 9 Oct 2026; read 12 Oct 2026.

The player display in portrait orientation: round 3 in a header band at the top, a column of portraits with the active one, Brother Ash, enlarged at the centre, and a vertical rail of round portraits down the right edge.
Fig. 4The same round at 720×1600: the dock stacks top to bottom, the order strip becomes a rail on the right, and the round sits in its own header band.