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 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 enginephysics 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 notation3d6, 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-friendly to_map/1 for 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

TokenMeaning
NdSRoll 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
! / !pExplode / explode & penetrate
r<op>nReroll (< <= = >= >); 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

:seed reseeds the calling process's :rand state to produce a reproducible sequence. The prior state is not restored, so in a long-lived process (e.g. a LiveView) a later unseeded roll/2 continues the seeded sequence rather than drawing fresh entropy. Thread :rng explicitly 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.

  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 dic_ex.min.js -> assets/vendor, dic_ex.css -> assets/css
    
  2. Wire the bundle:

    // assets/js/app.js
    import "../vendor/dic_ex.min.js"        // sets window.DicExHooks
    
    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

Options

OptionDefaultDescription
: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)
:rngnilRNG module; nilDicEx.RNG.Default (seedable)
:on_rollnilpid / 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.