All work

The DM Screen

A toolkit for the person running a tabletop game. One place for the characters, the fight, the world, the loot, and the four people at the table who each brought a phone.

RoleDesign, engineering, everything
Timeline2025 to now, continuing
StatusLive in production
StackNext.js, React, Supabase, Three.js, Rapier
9rule systems
166Postgres migrations
391React components
54routes

The problem

Running a tabletop game means holding about nine things in your head at once while five people watch you do it. The tools that exist mostly pick one of those things and do it well, so the person running the game ends up with a browser full of tabs, a PDF, a notebook, and a dice app that nobody else can see.

The second problem is narrower and more dangerous. Almost every tool in this category is built for one game, and that game is D&D. Building on top of somebody else's licence is a weak position: the company that owns the rules can ship your product as a feature whenever it likes. A tool that survives has to be the one people reach for regardless of what they are running that night.

The DM Screen campaign dashboard for a campaign called Descent to Dis, with the painted masthead, tab rows, and a session panel showing corruption and heat trackers.
A live campaign. The masthead art, the tab strip, and the body share one deep forest green field so the chrome has no visible seams, which took longer to get right than any feature on the page.

Approach: make the ruleset a first-class object

The fix was to stop treating D&D as the shape of the data and start treating it as one value of a variable. A system is a manifest. It declares its dice pattern, its abilities, the field that holds damage, the field that holds defence, its content sources, and the words it uses for the concepts D&D calls a class and a race.

The manifest

dice gives a primary die and a pattern, so d20+modifier-vs-target and d6-pool and 2d6+stat and fate-dice all describe themselves rather than being special-cased at the call site. combat names the health, armour, initiative and speed fields, which is how a system with stress instead of hit points and no armour class at all still renders through the same tracker. termscarries the vocabulary, because Blades in the Dark has playbooks and Call of Cthulhu has occupations and neither of them wants to see the word “race” on a character sheet.

Sparse manifests are the point. A system file only states what makes it different, and defaults layer in underneath, so adding a new game is a small file rather than a fork of every component that renders a character.

What that bought

  • Nine systems in production. D&D 5e on both the 2014 and 2024 rules, Pathfinder 2e, Draw Steel, Daggerheart, Mörk Borg, Blades in the Dark, Fate Accelerated, and Dungeon World.
  • Four dice grammars. A d20 against a target, a pool of d6 read for its highest, 2d6 plus a stat, and the four-sided Fate ladder, all through one roller.
  • One character sheet. Not nine sheets behind a switch. The sheet reads the manifest and renders whatever the system says a character is.
  • A reference library that ships with it. 339 spells and 262 magic items on the 2024 rules, and 400 Draw Steel monsters, all readable without an account and one click from a live game.
The system picker in The DM Screen, listing nine rule systems with a one-line description of each, and two quick games beneath them.
The picker a new table sees. Every entry is a manifest file, and the two quick games at the bottom run on the same dice stack as the campaigns.

Physics is theater. The engine is truth.

The dice problem, and the trick that solves it

People judge a dice roller in about four hundred milliseconds. It has to tumble like an object with mass, land, settle, and show a number that everyone at the table believes. That last word is the hard one.

The obvious build is to let the simulation decide: throw the die, wait for it to come to rest, read the face pointing up, and use that. It is also the wrong build. A rigid-body solver on a phone with a dropped frame, a die that lands on an edge and creeps for another second, a client whose WASM stepped at a slightly different rate: any of those changes the number. In multiplayer, two people watch the same roll land on different results, and the tool loses the only thing it actually sells, which is trust.

Rigged landing

So the order was inverted. The random number is drawn first, by the rules engine, before a physics world exists. The throw is then pre-simulated headless to rest, which face ends up pointing up gets read, and the authoritative value is painted onto that face before the visible tumble plays a single frame.

Why it holds

The die shows the correct number from frame one, so there is no swap at settle for a sharp-eyed player to catch. A physics desync cannot change a result, because the result was never physics' to decide. Multiplayer inherits the whole thing for free: broadcast one seed and every client tumbles the same throw to the same rest pose. And a die that has already settled stays interactive, because a flick reads the face the camera sees using the same projection the rig used, so a nudge lands somewhere consistent.

There is a second mode where physics genuinely does decide, for the freeform dice tray: no settle timer, no pre-simulation, no snap to rest. Dice tumble indefinitely and the parent reads live top faces on demand. Both modes run through the same canvas, gated on one prop, which is why the tray and the engine-decided rolls never drifted apart.

Details that took the longest

  • Snap to rest. When a die converges, only its orientation is squared to the nearest face. Its position stays exactly where it landed, so a die that rolled across the tray stays there instead of teleporting home.
  • The crystal d4. A tetrahedron rests vertex-up and has no top face to read. The d4 was rebuilt to rest on a flat side like every other die, so face-alignment is uniform across the set.
  • Shadows track the live rest. The blob shadow follows the real landing position and fades with height, which is most of why the dice read as sitting on felt rather than floating above it.
  • The whole stack is behind a dynamic boundary. Three.js and Rapier are several hundred kilobytes, and a page that never rolls a die should never pay for them.
The planar reference screen in The DM Screen with the dice tray open along the bottom edge, offering d4 through d100, and two 3D dice tumbling over the page.
The dice tray opens over whatever you were reading. The two dice in the field are the persistent fidget pair, on the physics-decides path.

Keeping a table in sync

A campaign is not one person's document. The person running the game has a dashboard, each player has a workspace on their own phone, and there is often a television in the room showing a third view. All of them are looking at the same fight.

That runs on three different Supabase mechanisms, on purpose, because they fail differently. Postgres change streams carry anything that is already a row and has to survive a refresh, like hit points and initiative order. Broadcast channels carry the things that are events rather than state, and the things a change stream is too slow or too chatty for. Presence carries the roster, and rides entirely in ephemeral channel state with no table and no SQL behind it.

What breaks, and what fixed it

  • Two subscribers, one channel. Supabase shares a realtime channel by name, so a second component chaining its own handler onto an already-subscribed channel throws. This happened in production whenever two presence surfaces mounted together. A small registry now hands every consumer for a campaign the same channel object and fans events out itself.
  • Broadcasts do not replay. A client that was not connected when an event fired never sees it. Anything that must survive a reconnect is a row first and a broadcast second, with the broadcast used only to front-run the slower path.
  • Presence lies at the edges. A phone that locks looks identical to a player who left. A grace fold sits over the raw roster so a brief drop does not empty the table.

Offline, without lying about it

This gets used in basements, in game shops, and at kitchen tables with bad wifi, so it installs as a progressive web app. The tempting build is cache-first, because it makes the app feel instant. For this app it is actively harmful.

A stale hit point total or a stale initiative order at a real table is worse than an honest error, because the person running the game will act on it. So navigations are network-first: online always wins, and a failure falls back to the last cached page and then to a page that says plainly that you are offline. Build assets are content-hashed, so those are stale-while-revalidate and route changes keep working. API calls, Supabase, and auth are never touched at all.

The service worker stamps a build timestamp into its cache name on every deploy, then claims open tabs immediately and purges every older shell. A user who has had the app open since last Tuesday gets the new one without being asked to do anything about it.

Where it landed

  • Nine rule systems from one codebase, with a tenth costing a manifest file rather than a fork.
  • 166 forward-only Postgres migrations with row-level security written next to the features that need it.
  • 391 React components and 326 library modules across 54 routes, with the 3D stack fully behind a dynamic boundary.
  • 60 test specs covering the parts that are expensive to get wrong: the rule engine, the merge rules, dice face reads, and migration guards.
  • Two quick games that share the dice stack, guests welcome, no campaign row, no account.
The reference library index, listing 262 magic items, 339 spells, and 400 Draw Steel monsters.
The reference library is open with no account.
The DM Screen welcome screen, with the wordmark in gold over a painted illustration and buttons to sign up, sign in, or continue as a guest.
Guests get in without an account, and can migrate later.
Next case studyDice Merge →