Current section
Files
Jump to
Current section
Files
README.md
<p align="center">
<strong>dicEx</strong><br/>
Pixel-art 3D dice roller for Phoenix LiveView
</p>
<p align="center">
<a href="https://github.com/akorda-software/dic_ex/actions/workflows/ci.yml"><img src="https://github.com/akorda-software/dic_ex/actions/workflows/ci.yml/badge.svg" alt="CI"/></a>
<a href="https://hex.pm/packages/dic_ex"><img src="https://img.shields.io/hexpm/v/dic_ex.svg" alt="Hex.pm"/></a>
<a href="https://hexdocs.pm/dic_ex"><img src="https://img.shields.io/badge/documentation-gray" alt="Documentation"/></a>
<a href="https://github.com/akorda-software/dic_ex/blob/main/LICENSE"><img src="https://img.shields.io/hexpm/l/dic_ex.svg" alt="License"/></a>
</p>
> 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 Elixir `to_map/1` and JSON-encodable `to_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`:
```elixir
defp deps do
[
{:dic_ex, "~> 0.4"}
]
end
```
Then:
```bash
mix deps.get
```
### Upgrading from 0.3
Update your dependency to `{:dic_ex, "~> 0.4"}` and run:
```bash
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:
> ```elixir
> Mix.install([{:dic_ex, "~> 0.4"}])
> DicEx.roll("2d20kh1 + 5")
> ```
<!-- MDOC -->
## Quick start
```elixir
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:
```elixir
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:
```elixir
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
```elixir
{: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:
```elixir
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.
<!-- MDOC -->
### Structured result
```elixir
%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
```elixir
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
```elixir
{: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):
```bash
mix dic_ex.install # copies the JS bundles -> assets/vendor, dic_ex.css -> assets/css
```
2. Wire the bundle:
```js
// 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, /* ... */ })
```
```css
/* assets/css/app.css — after the tailwind import */
@import "./dic_ex.css";
```
3. Drop the component anywhere — inline or in a modal:
```heex
<.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:
```elixir
<.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`):
```elixir
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:
```heex
<.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:
```heex
<.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/`:
```bash
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](./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](https://hexdocs.pm/dic_ex).
## Contributing
Development setup, quality gates, and the release/publish workflow live in
[CONTRIBUTING.md](./CONTRIBUTING.md). Bug reports and pull requests are welcome
at [github.com/akorda-software/dic_ex](https://github.com/akorda-software/dic_ex).
## License
Copyright (c) 2026 kukapu. Released under the [MIT License](./LICENSE).