dicEx
Pixel-art 3D dice roller for Phoenix LiveView

CI Hex.pm Documentation License

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:

Features

Installation

Add dic_ex to your mix.exs:

defp deps do
[
{:dic_ex, "~> 0.3"}
]
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.3"}])
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 (dS = 1dS)
d% Percentile die (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. A leading sign applies to the first term (-1d4+5). One reroll modifier per pool.

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.

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.

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.

  1. 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
  2. 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";
  3. 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" "obsidian", "arcane" or "dnd", or a custom palette map
: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 [] Parse limits: max_dice, max_sides, max_length
: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, _, _}

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"}} />

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, releasing the WebGL context and Rapier world it already owned; a queued roll keeps the server's 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; invalid reports fall back to the server's own roll.

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.