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.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.

  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" 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.