dicEx
Pixel-art 3D dice roller for Phoenix LiveView
D&D-style dice rolls in pure Elixir, with an optional Three.js + Rapier 3D visualization that drops into any LiveView.
dicEx computes dice rolls (advantage, drop/keep, explode, reroll) in Elixir so
modifiers always apply and results are seedable and testable. The tumbling 3D
dice are theatre — or, in the physics-is-truth 3D engine, exactly what happened.
The core has zero runtime dependencies; the LiveView component is opt-in.
Two reveal modes, both computed through Elixir:
- 2D engine — the server roll is the source of truth; the tumble lands on the value Elixir decided.
- 3D engine — physics is truth: dice land where Rapier takes them and the landed faces are reported back, so what you see is what happened.
Features
- Zero runtime dependencies for the core —
phoenix_live_view(+jason) are optional and only needed for the component. - Full dice notation —
3d6,2d20kh1(advantage),4d6dl1(ability scores),8d6!(explode),1d20r1(reroll),1d20+5. - Deterministic & seedable — replay rolls, anti-cheat, golden-path tests.
- Structured results —
%DicEx.Result{}with per-die outcomes, kept/dropped flags, and a JSON-friendlyto_map/1for LLM consumption. - 3D pixel-art dice — low-poly d4/d6/d8/d10/d12/d20 with procedurally drawn bitmap-font textures and real Rapier physics.
- Drop-in LiveView component — inline or modal, themed
(
obsidian/arcane/dnd).
Installation
Add dic_ex to your mix.exs:
defp deps do
[
{:dic_ex, "~> 0.1.0"}
]
end
Then:
mix deps.get
Try it in a Livebook with no project at all — the core needs no Phoenix:
Mix.install([{:dic_ex, "~> 0.1.0"}])DicEx.roll("2d20kh1 + 5")
Quick start
DicEx.roll("1d20") # => %DicEx.Result{total: 14, ...}
DicEx.roll("3d6 + 2") # => %DicEx.Result{total: 13, ...}
DicEx.roll("2d20kh1") # advantage — keep highest
DicEx.roll("4d6dl1") # 4d6, drop lowest
DicEx.roll("8d6!") # explode (fireball)
DicEx.roll("1d20r1") # reroll natural 1s
# safe variant for untrusted/LLM-generated expressions
{:ok, result} = DicEx.roll_e(prompted_by_the_llm)
# programmatic API matching a UI's "count + die + modifier"
DicEx.roll_dice(2, 20, mod: 5, advantage: true)
# reproducible
DicEx.roll("4d6", seed: 42)
Notation reference
| Token | Meaning |
|---|---|
NdS | Roll N dice of S sides (d4..d100) |
kh[n] | Keep highest n (advantage) |
kl[n] | Keep lowest n (disadvantage) |
dh[n] | Drop highest n |
dl[n] | Drop lowest n |
! / !p | Explode / explode & penetrate |
r<op>n | Reroll (< <= = >= >); ro rerolls once |
+ / - | Add / subtract pools or modifiers |
Only +/- compose — there's no *, /, or parentheses.
Reproducible rolls
Seed the default RNG for a reproducible sequence — useful for tests, replays, and anti-cheat:
DicEx.roll("2d20kh1", seed: 42)
A note on
:seed{: .warning}
:seedreseeds the calling process's:randstate to produce a reproducible sequence. The prior state is not restored, so in a long-lived process (e.g. a LiveView) a later unseededroll/2continues the seeded sequence rather than drawing fresh entropy. Thread:rngexplicitly when you need isolation, or re-seed per request.
Structured result
%DicEx.Result{
expression: "2d20kh1 + 5",
total: 23,
groups: [
%{kind: :dice, notation: nil, sides: 20, subtotal: 18, modifiers: [{:keep_high, 1}],
rolls: [%{value: 18, kept: true, exploded: false},
%{value: 7, kept: false, exploded: false}]},
%{kind: :modifier, notation: nil, sides: nil, subtotal: 5, modifiers: [], rolls: []}
]
}
DicEx.Result.to_map(result) # JSON-ready map for your LLM / client
The per-group notation is left nil; the full expression lives on the
top-level expression field.
Phoenix LiveView component (optional)
The 3D dice are an opt-in layer on top of the pure-Elixir core. It needs
phoenix_live_view and jason (both optional: true in dic_ex), and ships
prebuilt assets you import into your bundle.
Import the assets (Phoenix 1.8+ only serves
app.js/app.css, so dicEx is vendored, not referenced via external<script>tags):mix dic_ex.install # copies dic_ex.min.js -> assets/vendor, dic_ex.css -> assets/cssWire the bundle:
// assets/js/app.jsimport "../vendor/dic_ex.min.js" // sets window.DicExHooksconst hooks = { ...(window.DicExHooks || {}) }const liveSocket = new LiveSocket("/live", Socket, { hooks, /* ... */ })/* assets/css/app.css — after the tailwind import */@import "./dic_ex.css";Drop the component anywhere — inline or in a modal:
<.live_component module={DicExWeb.DiceRoller} id="dice-roller" />
Receiving rolls
Pass on_roll: self() and the host LiveView is notified with the full result,
ready to hand to an AI game master or any other consumer:
<.live_component module={DicExWeb.DiceRoller} id="roller" on_roll={self()} />
def handle_info({:dic_ex_rolled, %{result: result, component: id}}, socket) do
# result is a %DicEx.Result{} — feed its JSON map to the LLM
{:noreply, socket}
end
Options
| Option | Default | Description |
|---|---|---|
:default | "1d20" | Initial expression |
:theme | "obsidian" | "obsidian", "arcane" or "dnd", or a custom palette |
:engine | "3d" | "3d" (Three.js + Rapier) or "2d" (canvas, no physics) |
:rng | nil | RNG module; nil ⇒ DicEx.RNG.Default (seedable) |
:on_roll | nil | pid / registered name to receive {:dic_ex_rolled, _} |
Building assets from source
The package ships prebuilt assets. To rebuild after editing assets/src/:
mix dic_ex.build # bundles Three.js + Rapier -> priv/static/dic_ex.min.js
Requires Node.js + a JS package manager (pnpm/bun/npm; the build task installs deps automatically on first run). See CONTRIBUTING.md for the full development and release workflow.
Architecture
dic_ex/
├── lib/dic_ex.ex # public API: roll/2, roll_dice/3, format/1
├── lib/dic_ex/ # core: parser, roller, dice, result, rng
├── lib/dic_ex_web/ # LiveView component (guarded: needs LiveView)
├── lib/mix/tasks/ # mix dic_ex.build, mix dic_ex.install
├── assets/src/ # Three.js + Rapier scene, dice factory, hook
└── priv/static/ # prebuilt dic_ex.min.js + dic_ex.css
The roll is computed through Elixir for both engines. The 2D hook receives the
server result via push_event("dic_ex:roll", ...), tumbles the dice, and reveals
it in sync. The 3D hook throws the dice physically and, once they settle, reports
the landed faces back (dic_ex:landed) so Elixir recomputes the result around
the physics outcome — modifiers (kh/dl/explode…) still apply, and the revealed
total matches exactly what landed on the table.
Documentation
Full API docs are at hexdocs.pm/dic_ex.
Contributing
Development setup, quality gates, and the release/publish workflow live in CONTRIBUTING.md. Bug reports and pull requests are welcome at github.com/kukapu/dic_ex.
License
Copyright (c) 2026 kukapu. Released under the MIT License.