Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

150 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Dicebox

Dicebox deconstructed d20 mark

An offline dice roller for tabletop RPGs. No build step, no dependencies, no network.

Try it at dicebox.cc — that is a demo instance, not a service. To keep a copy of your own, see Getting it offline: download it as a single file, install it from the browser, or host it yourself.

Out of the box it does more than plain dice: ten game-system modes — Vampire, Daggerheart, Mothership and the rest — and two hand-traced card decks, all of it local and offline.

It stays a local dice roller unless you ask for more. Optionally it will also share rolls with a table over an end-to-end encrypted room, and there is an Owlbear Rodeo panel that joins one as an ordinary member. Both are opt-in, both are off until you open the Share panel, and neither is needed to roll dice.

Rolling 2d20+3d6, with the total and each die's result   Holding a die opens advantage, disadvantage, drop high or low, exploding and reroll   A mixed handful of d100, d30, d12 and d8 in dark mode

Tap dice to build a pool · hold one for modifiers · light and dark

About

This is a personal project. I kept running into the same problem — there was no clean, open source, offline-capable web app for rolling dice — so I built one. If you have had the same problem, you are welcome to it.

It was made with Claude Code. If that is not your bag, that is completely fine: write your own, fork this, do whatever you want with it. Questions, comments, issues, pull requests and forks are all welcome.

MIT licensed.

Using it

Tap dice to build a pool: tap d20 twice and d6 once and you have 2d20+1d6, staged on the tray and written into the notation field. Press Roll, or flick the tray, to throw them. The pool survives the roll, so re-rolling the same handful is one more tap.

Typing notation by hand works the same way — the field is the source of truth, and tapping a die extends whatever is already there.

The number on the left is how many dice each tap adds, so 100 then d6 gives you 100d6 without a hundred taps.

Hold a die for advantage, disadvantage, drop high/low, exploding and reroll. Modifiers that answer different questions stack — 4d6dl1! drops the lowest and explodes — while two that answer the same one replace each other.

The d? button opens a picker for any side count from 1 to 1000, and a die you choose there gets a button of its own.

Notation

Input Meaning
3d6 three six-sided dice
d20 one d20 (count defaults to 1)
1d20+5 with a modifier
2d6+1d8-1 any number of terms
d% percentile, same as d100
4d6kh3 keep highest 3 (ability scores)
2d20kl1 keep lowest 1 (disadvantage)
4d6dl1 drop lowest 1
1d6! exploding — reroll and add on a max face
1d10r2 reroll any result of 2 or lower

Sides are arbitrary from 1 to 10000, so Mothership's d100, DCC's d14/d24, and anything else all work. Dropped dice stay visible in parentheses rather than disappearing.

Game systems

Plenty of games do not roll a plain sum. Dicebox has a mode for a number of them, each with its own dice, symbols and reading of the result. Pick one from the switcher in the top bar — the icon beside the wordmark — and the tray, the picker and the notation all change to match.

A grid of every Dicebox mode — numeric, Vampire V5, Fate, Genesys, Star Wars, Daggerheart, CthulhuTech, The One Ring, PbtA, Mist Engine, Mothership, Cards and Tarot — each with its own dice and colour palette

System Notation What it rolls
Vampire V5 v5:8h3 a d10 pool with Hunger, successes and messy criticals
Daggerheart dh:+2@15 the Hope and Fear duality dice
Fate / Fudge 4dF+2 the four +/blank/− dice and the ladder
Genesys gen:2A+1P+2D the narrative dice, symbols cancelling out
Star Wars sw:2A+2D+1F the same narrative dice, plus the Force die
Mist Engine mist:+1 2d6 + Power — City of Mist, Legend in the Mist
PbtA pbta:+1 2d6 + stat, the 10+ / 7–9 / 6− ladder
The One Ring 2e tor:3@14 the Feat die and a pool of Success dice
CthulhuTech 2e ct:8@4 a d10 pool counting even-face hits
Mothership 1e ms:c@35 d100 roll-under, Panic, and tracked Stress

Each mode has a picker built for its own dice — tap to build the pool, hold to adjust — and a help panel (the ? in the top bar) with its full rules and notation. Symbols are drawn the way the official dice show them, and the plain numbers they landed on are always in the line under the result. Ordinary dice still roll in any mode, so weapon damage or a stray d20 needs no mode change.

Every mode has its own short URL — dicebox.cc/dh, /mosh1e, /vtmv5 and so on — which opens Dicebox straight into it.

Card decks

Five of the modes deal cards instead of dice. The deck sits on the tray like a dealer's shoe: tap it to draw, and the cards deal, flip and settle the way the dice tumble. Draws come off a real shuffled order and the deck remembers what is gone until you shuffle. The cards are physical things: tap a drawn card to pick it up for a closer look, flick it to send it to the discard pile without drawing, and tap the discard pile to page back through everything in it.

The Woodcut Tarot and the Woodcut playing-card deck — trumps, courts, an ace, a joker and the card back, all traced from antique woodcuts

Cards is a 52-card French-suited deck, jokers optional. Tarot is the full 78-card Tarot de Marseille, with reversals and a majors-only option. Napoletane is the 40-card Italian deck of Scopa and Briscola, in full stencil colour from a deck printed in Naples in 1902 — a mode that speaks Italian, from the picker to the help panel. Hanafuda is the 48-card Japanese flower deck of Koi-Koi, in Louie Mantia, Jr. and すけじょ's traditional-colour set (CC BY-SA 4.0, via Wikimedia Commons) — the one deck in the box drawn by other hands. Everything the pickers do is typeable too — deck:3 jokers replace, tarot:3 majors upright, nap:3, hana:8 — so the notation still describes the whole draw. Uta-garuta is the 100 yomifuda of the Ogura Hyakunin Isshu — draw a single card (uta:1) and the whole poem reads out under the result, with Clay MacCauley's public-domain translation; the help panel carries the Bōzu Mekuri rules, and the portraits and calligraphy are traced from Hishikawa Moronobu's illustrated edition (Edo, 1680), digitised by the Library of Congress.

The woodcut art is not clip-art. Those decks are traced by hand from public-domain woodcut cards held by the Bibliothèque nationale de France — as single-colour vector line that retints cleanly for light and dark, and in full stencil colour with each deck's own palette measured from the scans. They are released as their own CC0 repositories, usable well outside Dicebox:

How the rolls work

Every die is decided by crypto.getRandomValues, the browser's cryptographic random source. Math.random() is not used anywhere in the roll path — it is a fast pseudo-random generator, seeded per page and predictable given enough output, which is fine for animation and wrong for the numbers.

Turning random bytes into a die takes some care. Asking for a 32-bit number and taking % 20 is the obvious approach and it is subtly unfair: 2³² does not divide evenly by 20, so the first few faces come up very slightly more often. Instead a value is drawn and rejected if it falls in the remainder at the top of the range, then drawn again. Every face ends up equally likely, and the loop almost always finishes on the first try.

The animation is a picture, not a physics engine

The number is decided the instant you roll — before anything moves. What follows is a drawing of that outcome, not a simulation that produces it.

The dice do tumble, bounce off the walls of the tray, push each other apart and settle showing a face, but none of it feeds back into the result. A die that lands showing 17 was already a 17. This is deliberate: a real physics simulation would make the outcome depend on frame timing, floating-point rounding and how hard you flicked, none of which are fair or reproducible. Watching the dice should be enjoyable; it should not be what decides the roll.

The same goes for the tidying afterwards — dice drift into a sorted grid, group by type and order high to low. That is presentation.

Why a d100 does not have exactly 100 faces you can count

Each die is drawn as a real solid whose face count matches its side count, up to a point. From d3 to d120 that holds for 108 of the 118 possible dice — a d17 has seventeen faces, a d100 has a hundred.

Past that, the limit is your eyes rather than the maths. At the size a die is drawn on a phone, facets start landing closer together than a pixel, and the wireframe stops reading as an object and becomes a grey smudge. Dice above ~120 sides therefore get a representative shape: still a distinct, consistent solid for that number, but no longer one facet per side. A d1000 drawn honestly would be a circle.

Shape matters too, not just count. A trapezohedron — the classic d10 form — runs every facet to one of two points, so its edges converge and it is already crowded at 24 faces. A banded drum spreads its vertices evenly and stays countable past 120. That is why dice above 22 sides change family: not for decoration, but because it is the shape that survives being small.

Rooms

A room shares your rolls with other people in real time. Everyone sees the same log; each screen animates its own dice from the result. It is for playing at a distance with people you already know.

Rooms are opt-in and off by default. Dicebox is a local dice roller first and stays one — if you never open the Share panel, nothing about the app talks to a network, and none of what follows applies to you.

You join by passphrase. The app generates a short phrase of ordinary words; whoever has it is in the room, and that is the whole membership model. Share it however you already talk to each other. The passphrase is what derives the encryption key, so it never travels over the network — the relay never receives it, is never sent it during a join, and has no way to ask for it.

Rolling stays local whatever the network does. If the relay is unreachable your dice still land instantly; the app tells you it cannot share and carries on. No spinner, no queue, no waiting. A roll that arrives ten minutes late is worse than one that never arrives, so late rolls are dropped rather than held.

Rooms are ephemeral. Nothing is stored, so someone joining halfway through sees rolls from that moment on and no earlier. A room dies after 30 minutes idle, and in any case 12 hours after it was created — a departed player keeps the passphrase forever, so expiry is the only thing here that resembles taking access away.

How it fits together

Three pieces, and the boundaries between them are the point:

  your browser                    a relay                  other browsers
  ────────────                    ───────                  ──────────────
  passphrase ──► key                                          key ◄── passphrase
       │                                                             │
       ▼                                                             ▼
  roll ─► encrypt ─────────► ciphertext, fanned out ─────────► decrypt ─► roll
                             (never holds a key,
                              never stores a byte)
  • The app is static files. Any web server will do, and it needs no backend at all unless you want rooms.
  • The relay is a separate service on a separate origin, which is what makes the guarantee checkable rather than a promise: a different party serves the code than carries the messages.
  • The passphrase never leaves the browser. It derives a room id the relay sees and a key the relay never does, through PBKDF2 and HKDF with different info strings, so learning one tells you nothing about the other.

The app finds its relay through one meta tag in index.html, and the browser is only allowed to reach that exact origin because the connect-src in your headers names it. Both have to agree or the socket is refused — which is deliberate, and covered under Running your own relay.

Nothing above applies until someone opens the Share panel. With no relay configured the app is a local dice roller and the room code never runs.

Running your own relay

The relay is a small WebSocket server that forwards encrypted messages between people in the same room. It is a fanout and nothing else: it cannot read a roll, it never decides one, and it writes nothing to disk.

There are two implementations of it, speaking the same protocol. Start with the Node one — it runs anywhere, and you can read the whole thing:

node server/relay.mjs                    # 127.0.0.1:8787
docker compose --profile rooms up -d     # or as a container

The rooms profile means the relay only starts when you ask for it — plain docker compose up -d brings up the static app exactly as before.

The second is a Cloudflare Worker backed by Durable Objects, which is what the demo runs. It exists for one reason: a dice room is idle between rolls, and the hibernation API lets an idle room evict from memory while its sockets stay open. If you are not paying per-request for a public instance, you do not need it.

node tools/deploy-relay.mjs              # needs .env, see .env.example

server/README.md covers both, what differs between them, and why.

It binds loopback by default, so a fresh install is not reachable from the network until you decide it should be. Put it behind whatever TLS terminator you already run, then point the app at it by changing the relay origin in docker/security-headers.conf (self-hosted) or worker.js (Cloudflare).

One thing to set while you are there: turn off access logging on whatever terminates TLS in front of the relay. The room id travels in the request URL, so nginx and Caddy both write it to disk by default — the full id, not the eight-character prefix the relay itself logs, and it stays there long after the room has expired. server/README.md has the configuration.

That origin is pinned to one exact host rather than opened up to wss: or *, and it is worth saying why. The relay is never given key material by design; the pinned origin is what keeps that true even if the design fails. A build tampered with somewhere between the server and the browser still cannot send anything to a host that is not on that line, because the browser refuses the connection outright. Widening it to make a setup problem go away gives that protection up entirely.

server/README.md covers the configuration — limits, timeouts, origins — in full.

In Owlbear Rodeo

There is an Owlbear Rodeo panel, which joins a room as an ordinary member — same passphrase, same rolls, no special status. It is the same app built for an iframe rather than a second implementation.

To add the hosted panel: in Owlbear Rodeo open Profile → Add Extension and paste

https://vtt.dicebox.cc/manifest.json

The Dicebox panel then appears in the room's toolbar. Everyone who wants the shared log joins the same room with the same passphrase — in the panel, in a browser tab, or on a phone; they are all the same kind of participant.

To host your own panel instead of using that one:

npm run build:owlbear -- --relay=wss://relay.example.com/ws --host=vtt.example.com
npm run deploy:owlbear

It has to be its own origin, not a path on the app's — the panel permits being framed by Owlbear and the app must never be, and the app's service worker would otherwise claim the panel as well. The deploy refuses to publish onto the app's hostname rather than relying on anyone to remember that.

Rolls are not published into the Owlbear room, so players without the passphrase see nothing. Doing that would mean holding the room key and handing plaintext to Owlbear's servers, which is a different feature with a different privacy story rather than a convenience to add quietly. owlbear/README.md covers hosting, headers and installing.

What the privacy actually is

Rolls are encrypted in your browser and decrypted in the other players' browsers. The relay only ever sees ciphertext. Names, notation, dice values and totals are all inside it.

Self-hosting, or the downloaded single file, gives a real "we cannot see your rolls" guarantee. You are running the code, and you can read it.

The hosted demo does not, and this is a genuine difference rather than a technicality. dicebox.cc serves the JavaScript that does the encrypting. Encryption in the browser is only as trustworthy as the code the browser was handed, so using the demo means trusting me to keep serving honest code — not just today but on every load, since the app updates itself. I have no plans to do otherwise, but a promise is not a guarantee, and you should not accept one where you can have the real thing. If that distinction matters for your table, self-host or use the single file. Both are a few minutes of work and are why they exist.

Two more limits worth being straight about, and neither is fixable by encryption:

A relay sees traffic shape. It cannot read content, but it knows a room exists, how many connections are in it, when they arrive and leave, and roughly how much they send. Who was playing and when is visible to whoever runs the relay, even though what you rolled is not. Running your own is the answer if that matters.

Rolls are generated on each player's own device. The app rolls honestly with crypto.getRandomValues and reports the result, but a modified client could report anything, and no amount of cryptography can tell an encrypted lie from an encrypted truth. The app does check incoming rolls for internal consistency — that the dice add up to the total — which catches a broken client and would not catch a determined one. There are deliberately no "verified roll" badges, because they would suggest a guarantee that does not exist. Rooms are for friends. If you need dice nobody at the table could have tampered with, you need someone else holding them.

The roll log

Every roll is kept for the session, with what each die landed on and when. Full history under the recent rolls opens the lot, and exports it:

  • Copy puts a readable log on the clipboard
  • CSV gives one row per die — time, notation, total, sides, value, and whether it was kept, exploded or rerolled — which is the shape you want for counting faces or checking whether a die is drifting
  • JSON is the same data with the structure intact

Nothing leaves the browser unless you export it.

Getting it offline

Three ways, easiest first.

Download one file

Grab dicebox.html — or the copy in dist/ — and open it. That is the entire app in a single file: no server, no install, no network. Put it on a USB stick, email it to yourself, keep it in a folder with your character sheets. It works the same on a laptop with the wifi off.

The help panel inside the app links to it too.

Install it from the web

Open the demo and install it. After the first load it runs offline, because a service worker keeps a local copy.

Browser How
Chrome, Edge (desktop) Install icon in the address bar, or ⋮ → Cast, save and share → Install page as app
Chrome (Android) The Install as an app button in the help panel, or ⋮ → Add to Home screen
Safari (iOS/iPadOS) Share → Add to Home Screen
Safari (macOS) File → Add to Dock
Firefox No install support on desktop. Bookmark it — it still works offline once loaded — or use the single-file build above

Run your own copy

There is no build step and no backend, so anything that serves a directory over HTTP will do. It needs http:// rather than file:// only so the service worker can register; the single-file build has no such requirement.

python3 -m http.server 8080     # or: npx serve, php -S localhost:8080, caddy file-server

Docker

If you would rather run it as a container:

docker compose up -d            # http://localhost:8080

The image is nginx with the app copied in — nothing is compiled and nothing is fetched at runtime. The bundled nginx config applies the same security and cache headers the hosted copy uses. To serve on a different port, change the mapping in docker-compose.yml.

For a home network, put it behind whatever reverse proxy you already run. It needs HTTPS only if you want to install it to a phone's home screen; browsers require a secure context for that, with localhost exempt.

Working on it

npm test          # every suite, including the single-file bundle and the panel
npm run bundle    # rebuild dist/dicebox.html on its own
npm run build:owlbear -- --relay=wss://...   # the Owlbear panel

Deploying

Everything goes to Cloudflare Workers, and every script reads .env — see .env.example for what has to be in it.

npm run deploy:dev        # dev.dicebox.trollskull.cc
npm run deploy            # the live site
node tools/deploy-relay.mjs [--dev]   # the relay
npm run deploy:owlbear    # the Owlbear panel, after build:owlbear

Each is a separate script rather than one with flags, because they have almost nothing in common: the app is static assets fronted by a header-setting Worker, the relay is a script with a Durable Object namespace and no assets, and the panel is a generated directory on an origin of its own.

Three things are handled for you, and all three used to be documented instructions that someone had to remember:

  • The service worker's cache name is derived from a hash of everything shipped beside it. A stale cache name is the worst bug this project has, because it has no symptom at the origin: every file is correct and installed copies keep serving the previous build regardless.
  • The build id shown in the help panel is the same hash, so a copy that says which build it is cannot be wrong about it.
  • The relay origin is substituted into index.html at deploy time from RELAY_URL, so staging and production can point at different relays without the two ever differing in the repository.

The single-file build in dist/ is committed, so rebuild it with npm run bundle when you change anything it inlines. npm test does it anyway.

About

An offline-first dice roller for tabletop RPGs: any die from d1 to d1000, a dozen system modes (VtM V5, Daggerheart, Mothership, and more), five card decks, and optional end-to-end-encrypted shared rooms. No build step, no dependencies.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages