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.
Tap dice to build a pool · hold one for modifiers · light and dark
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.
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.
| 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.
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.
| 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.
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.
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:
- woodcut-cards — the 52-card deck, two jokers and the back
- woodcut-tarot — all 78 cards of the tarot
- woodcut-napoletane — the 40 carte napoletane, keyline and colour
- woodcut-utagaruta — the 100 yomifuda of the Hyakunin Isshu, with the poems as data
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 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.
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.
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.
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.
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 containerThe 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.exampleserver/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.
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:owlbearIt 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.
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.
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.
Three ways, easiest first.
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.
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 |
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-serverIf you would rather run it as a container:
docker compose up -d # http://localhost:8080The 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.
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 panelEverything 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:owlbearEach 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.htmlat deploy time fromRELAY_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.




