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 dice
are theatre: the server decides, the dice land on what it decided.
The core has zero runtime dependencies; the LiveView component is opt-in.
The component supports these reveal modes:
- 2D engine — the tumble lands on the value Elixir decided.
- 3D engine (default) — dice tumble with real Rapier physics and settle on the faces Elixir decided. Dice the 3D engine can't show exactly (d100, d7…) are shown in 2D for that roll.
- Physics-is-truth 3D (opt-in,
physics={true}) — dice land where Rapier takes them and the landed faces become the result. The browser decides the outcome, so a player can cheat; use it only where that is acceptable. It applies to d4/d6/d8/d10/d12/d20 pools with optional keep/drop only; rerolls, explosions, clamps and success/failure counters stay server-decided.
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),d%(percentile),1d20+5. - Safe on untrusted input — bounded dice count, sides and length;
never-ending modifiers (
1d6r<=6,1d1!) are rejected. - Deterministic & seedable — replay rolls, anti-cheat, golden-path tests.
- More game systems — Fate/Fudge dice, success/failure pools, conditional explosions and per-die minimum/maximum values.
- Custom dice — numeric or labelled faces with optional integer weights.
- Exact odds — bounded probability distributions, advantage odds, means and variance without consuming random numbers.
- Reusable rolls — precompiled expressions, batches and explicit RNG state for reproducible sessions.
- Structured results —
%DicEx.Result{}with per-die outcomes, kept/dropped flags, an Elixirto_map/1and JSON-encodableto_json_map/1. - 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 with configurable trays, presets, sizes, animation and accessible results.
Installation
Add dic_ex to your mix.exs:
defp deps do
[
{:dic_ex, "~> 0.4"}
]
end
Then:
mix deps.get
Upgrading from 0.3
Update your dependency to {:dic_ex, "~> 0.4"} and run:
mix deps.update dic_ex
mix dic_ex.install # LiveView users: refresh the vendored JS and CSS
Then rebuild your application's assets using its usual build command. Ship the updated Elixir package and assets together: the 3D geometry corrections and final physics-result updates require the new bundle. Core-only users do not need the asset installation step. Existing valid notation, result maps and host notification messages remain supported.
Try it in a Livebook with no project at all — the core needs no Phoenix:
Mix.install([{:dic_ex, "~> 0.4"}]) 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
DicEx.roll("4dF + 2") # Fate/Fudge: each die is -1, 0 or +1
DicEx.roll("8d10cs>=7cf=1") # count successes, subtract failures
DicEx.roll("3d6!>=5") # explode on 5 or 6
DicEx.roll("2d6min3") # each die contributes at least 3
# 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)
DicEx.roll_dice_e(1, 20, mod: 5) # tuple result, with typed-input validation
# reproducible
DicEx.roll("4d6", seed: 42)
Notation reference
| Token | Meaning |
|---|---|
NdS |
Roll N dice of S sides (dS = 1dS) |
d% |
Percentile die (d100) |
dF |
Fate/Fudge die: equally likely -1, 0, +1 |
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 |
!>=n |
Explode when the raw face matches a comparator |
r<op>n |
Reroll (< <= = >= >); ro rerolls once |
minN / maxN |
Clamp each recorded face to a minimum/maximum |
cs<op>N |
Count kept faces matching the success condition |
cf<op>N |
Subtract kept faces matching the failure condition |
+ / - |
Add / subtract pools or modifiers |
Only +/- compose — there's no *, /, or parentheses. A leading sign
applies to the first term (-1d4+5). One reroll modifier per pool.
Bare rN retains its existing meaning: reroll faces less than or equal to
N. Use r=N for equality. Comparators accept zero and negative thresholds,
including dFr=-1. !p>=5 combines conditional explosion with penetration.
Rules run in this order: reroll base dice, explode, clamp values, keep/drop,
then score. With cs or cf, a group's subtotal counts matching kept dice
instead of summing face values; a face matching both conditions cancels out.
Roll records retain their numeric faces. As before, the last keep/drop
modifier wins. Only one cs, cf, min and max is allowed per pool;
contradictory minimum/maximum values are rejected.
Limits
Expressions are bounded so untrusted input can't exhaust the process: at most 100 dice in total, 1000 sides per die and 256 characters. Override per call:
DicEx.roll_e("150d6", max_dice: 200, max_sides: 1000, max_length: 256)
A repeating reroll that matches every face (1d6r<=6) and exploding a d1 are
rejected, since they would never finish.
The same check applies to conditional explosions. A max_rolls budget
(default 20_000) counts all random draws in one expression, including
discarded rerolls and explosion chains. Exceeding it returns an error from
roll_e/2. The existing per-die limits of 100 rerolls and 50 bonus explosion
dice still apply. Options and malformed UTF-8 input return validation errors.
Reproducible rolls
Pass a seed for a reproducible sequence — useful for tests, replays, and anti-cheat audits:
DicEx.roll("2d20kh1", seed: 42)
The seeded state is private to the call (DicEx.RNG.Seeded); the calling
process's :rand state is left untouched.
Compile, batch and continue a sequence
{:ok, attack} = DicEx.compile("2d20kh1+5")
DicEx.roll(attack, seed: 42)
# Parse once, advance one RNG stream: six independent ability rolls.
{:ok, abilities} = DicEx.roll_many("4d6dl1", 6, seed: 42)
Enum.map(abilities, & &1.total)
# Continue a reproducible session across calls.
{:ok, first, rng} = DicEx.roll_with_state("d20", seed: 42)
{:ok, second, rng} = DicEx.roll_with_state("2d6", rng: rng)
Batches accept 0–10,000 results. Parse limits supplied when rolling a compiled
expression are rechecked. Custom RNG modules can implement roll(sides) or
roll(sides, state); the stateful callback returns {face, next_state}. Pass
{module, initial_state} as :rng. Existing deterministic and seeded RNG
tuples keep their original behaviour.
For large simulations, consume a lazy stream instead of retaining a batch:
DicEx.stream("2d6", seed: 42)
|> Stream.take(100_000)
|> Enum.reduce(0, fn result, sum -> sum + result.total end)
The stream parses once, advances one RNG sequence and retains no previous
results. It is infinite: use a bounded consumer. Invalid input raises when
creating the stream; draw-budget or custom-RNG errors occur during consumption.
Re-enumerating a seeded stream starts the same sequence again. The safe
programmatic roll_dice_e/3 validates integer counts, sides and modifiers before
building notation, so typed form values cannot inject extra dice expressions.
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) # original Elixir map, including tuples
DicEx.Result.to_json_map(result) # ready for Jason.encode!/1 or other encoders
For expression rolls, per-group notation is left nil; the full expression
lives on the top-level expression field. Custom dice use their name as the
group notation.
Custom faces and weighted dice
die = DicEx.Die.new!([
%{value: 0, label: "miss", weight: 3},
%{value: 1, label: "hit", weight: 2},
%{value: 2, label: "critical", weight: 1}
], name: "combat")
result = DicEx.Die.roll(die, 4, seed: 42)
DicEx.Result.to_json_map(result)
# Plain integers are equally weighted faces; duplicates are intentional.
coin = DicEx.Die.new!([0, 1], name: "coin")
DicEx.Die.roll_e(coin, 3)
Faces can have negative or zero values. Weights are positive integers, so
sampling has exact relative weights without rounding. Labels are descriptive;
the total sums the numeric values. A custom die supports up to 1,000 faces,
weights up to 1,000,000 and names/labels up to 80 characters. Custom results
use sides: :custom and their display expression (such as 4d[combat]) is
not parser notation. Host-pushed custom results use the 2D renderer.
Exact probabilities
{:ok, odds} = DicEx.Statistics.distribution("2d20kh1+5")
DicEx.Statistics.probability(odds, :ge, 20) # hit a target of 20
{:ok, ability} = DicEx.Statistics.distribution("4d6dl1")
ability.mean
ability.frequencies[18] / ability.outcomes
Frequencies and total outcome counts are exact integers. Mean, variance and
threshold probabilities are floats. This supports constants, arithmetic,
ordinary/Fate dice, keep/drop, clamping and success/failure counts. Explosions
and rerolls return an unsupported-expression error. Computation is bounded by
max_states (default 10_000) and max_work (default 1_000_000); requests
that exceed a budget return an error.
Keep/drop distributions use combinatorial dynamic programming, including
clamped ties and success/failure scoring. Pools such as 100d20kh1 and
30d6kh10 fit the default budgets. State limits also cover intermediate
selection states, and work limits include combinatorial calculations.
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 the JS bundles -> assets/vendor, dic_ex.css -> assets/css -
Wire the bundle:
// assets/js/app.js import "../vendor/dic_ex.min.js" // sets window.DicExHooks (2D + 3D, ~2.7 MB) // or, if every roller uses engine="2d", the lightweight bundle: // import "../vendor/dic_ex_2d.min.js" // 2D only, a few KB const 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
Host-owned results
To animate a result your own code already decided, push it to the roller's
hook directly (here the component's DOM id is spell-stage):
push_event(socket, "dic_ex:roll:spell-stage", %{
groups: [%{sides: 4, rolls: [%{value: 3, kept: true}]}], authoritative: true
})
dic_ex:landed:spell-stage then reports the supplied values; treat it as a
"settled" signal, never as a replacement for your committed result.
d4/d6/d8/d10/d12/d20 use 3D; other sides reveal the exact value in 2D for that
roll. DicEx.renderer_capabilities/0 returns %{authoritative_3d: true} so
hosts can feature-detect it across versions.
Component options
| Option | Default | Description |
|---|---|---|
:default |
"1d20" |
Initial expression (re-applied only when it changes) |
:theme |
"obsidian" |
Built-in name (obsidian, arcane, dnd, frost, forest, neon) or palette map |
:theme_overrides |
%{} |
Safe palette overrides layered on the selected theme |
:engine |
"3d" |
"3d" (Three.js + Rapier) or "2d" (canvas, no physics) |
:physics |
false |
true ⇒ 3D landed faces become the result (client-decided) |
:rng |
nil |
RNG module or {module, state}; nil ⇒ DicEx.RNG.Default |
:limits |
[] |
Limits: max_dice, max_sides, max_length, max_rolls |
:labels |
English | %{add:, clear:, roll:, rolling:, placeholder:, input:} |
:reveal_timeout |
6000 |
ms before revealing if the dice never report settling |
:on_roll |
nil |
pid, name, {name, node}, {:global, _} or {:via, _, _} |
:quick_dice |
Standard tray | List of sides or notation labels, e.g. [6, 20, "d%", "dF"] |
:presets |
[] |
Named rolls: [%{label: "Attack", expression: "2d20kh1+5"}] |
:size |
"md" |
"sm", "md" or "lg" |
:stage_height |
nil |
Stage height in pixels, 120–1200; nil uses the stylesheet |
:show_controls |
true |
Show the tray, presets and expression form |
:show_stage |
true |
Show the dice stage; hidden stages reveal results immediately |
:animation |
"auto" |
"auto", "full", "reduced" or "none"; auto respects reduced-motion preference |
For a Spanish UI, for example:
<.live_component module={DicExWeb.DiceRoller} id="roller"
labels={%{add: "añadir", clear: "limpiar", roll: "Tirar", rolling: "tirando…",
input: "Expresión de dados"}} />
Combine a themed tray with game-specific presets:
<.live_component module={DicExWeb.DiceRoller} id="combat"
theme="forest" theme_overrides={%{accent: "#fbbf24"}}
quick_dice={[6, 10, 20, "dF"]}
presets={[
%{label: "Attack", expression: "2d20kh1+5"},
%{label: "Damage", expression: "2d6min2+3"},
%{label: "Skill", expression: "6d10cs>=7cf=1"}
]}
size="lg" stage_height={360} animation="auto"
on_roll={self()} />
Palette updates repaint dice already on the stage. Reduced motion uses a
brief static 2D reveal; animation="none" reveals immediately. Fate, custom
faces and clamped values that a physical die cannot represent use 2D for that
roll. Roll payloads, component callbacks and nonce handling remain compatible.
The 2D renderer reuses up to 128 cached face canvases per component (at most 2 MiB of face pixels), invalidates them on theme changes and releases them on teardown. Hidden tabs pause animation work. Invalid component limits, non-text or invalid-UTF-8 defaults, and invalid reveal timeouts display a configuration error and block rolls until corrected. Dice syntax errors are shown on submission.
The d4 uses conventional vertex numbering: read the value repeated beside the upper vertex on its visible faces. Other 3D dice show their result on the top face; authoritative results are oriented for reading, and 6/9 have bottom markers. Dropped dice are dimmed, including after a physics-mode result is confirmed. Dense pools use smaller dice and an adjusted camera to keep every die visible. The renderer never silently drops outcomes from a large pool.
Result.to_roll_event/1 includes an exact display_value string only for
integers outside JavaScript's safe numeric range. The numeric value field is
retained; the 2D renderer displays long scores as text so digits are not rounded
away or clipped out of the bitmap font.
Building assets from source
The package ships prebuilt assets. To rebuild after editing assets/src/:
mix dic_ex.build # -> priv/static/dic_ex.min.js and dic_ex_2d.min.js
mix dic_ex.test_assets # Node's built-in lifecycle and face-orientation tests
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.
The 3D hook recovers to 2D if scene initialization fails or the WebGL context is lost, releasing the graphics and physics resources it already owned; a queued or committed roll keeps its values. Hidden or zero-sized canvases pause, settled scenes stop their animation loop, and a die that never comes to rest is locked after 4.5 s so a roll always finishes.
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/ # dic_ex.install (+ dev-only build, test_assets)
├── assets/src/ # Three.js + Rapier scene, dice factory, hook
└── priv/static/ # prebuilt dic_ex.min.js, dic_ex_2d.min.js, dic_ex.css
The roll is computed in Elixir for both engines. The component pushes the
result to its hook (dic_ex:roll:<id>, tagged with a nonce); the hook animates
it and reports back (dic_ex:settled:<id> / dic_ex:landed:<id>) so the result
is revealed in sync, with a server-side timer as a fallback. Reports from a
superseded roll are ignored. In physics mode the landed faces are validated
(count and range) and Elixir recomputes the result around them so keep/drop
still apply using the original roll's limits; invalid reports fall back to the
server's own roll. Physics-mode commits send an additive dic_ex:result:<id>
event with the same nonce to correct kept/dropped visuals without another
tumble or another host notification.
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/akorda-software/dic_ex.
License
Copyright (c) 2026 kukapu. Released under the MIT License.