Design: the installed-set list (options 1s and 1t), the set details (6a, and 6b for a failed validation), the disable/remove dialog (5a) and the update flow (9h–9i) are in the clickable design (design/).
A dice set is a folder containing a diceset.toml file and, optionally,
textures. The built-in dice are a dice set too; there is no privileged code
path for them. Anyone can publish a set on a git forge or as a plain archive
on any https server, and users can install it from inside the app.
The same package format also carries tables (the look of the dice tray);
see docs/tables.md. A package may contain dice, tables, or both.
Design rules, in priority order:
my-dice/
diceset.toml
textures/
d20.png
d6-face-1.png
…
README.md (optional, shown in the app's set details)
LICENSE (optional, shown in the app's set details)
Only diceset.toml is required. Anything not referenced from it is ignored.
diceset.tomlformat = 1 # schema version, required
[set]
id = "brass-and-bone" # [a-z0-9-], 3..40 chars, unique per install
name = "Brass & Bone"
version = "1.2.0" # semver, used for updates
author = "Ada Example"
license = "CC-BY-4.0"
description = "Brass numerals on bone-coloured resin."
homepage = "https://github.com/ada/brass-and-bone"
[defaults] # applied to every die unless overridden
color = "#e8dcc0"
number_color = "#8a6d1e"
roughness = 0.35
metallic = 0.0
size_mm = 16 # clamped to 8..40
density = 1.2 # g/cm³, clamped to 0.5..8
restitution = 0.3 # clamped to 0.0..0.8
friction = 0.5 # clamped to 0.1..1.0
# --- dice -----------------------------------------------------------
[[die]]
id = "d6" # what the notation "d6" resolves to
shape = "cube" # from the shape catalogue
faces = [1, 2, 3, 4, 5, 6] # value per face, in the catalogue's face order
texture = "textures/d6.png" # atlas; layout defined by the catalogue shape
[[die]]
id = "d20"
shape = "icosahedron"
faces = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
texture = "textures/d20.png"
color = "#2b2b2b" # per-die override
[[die]]
id = "d4"
shape = "tetrahedron"
read = "vertex-up" # value at the top vertex, not the top face
faces = [1, 2, 3, 4] # for vertex-up shapes: value per *vertex*
[[die]]
id = "d10-tens"
shape = "pentagonal-trapezohedron"
faces = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]
labels = ["00","10","20","30","40","50","60","70","80","90"]
[[die]]
id = "skull-d6" # any id; reached from the dice picker
shape = "cube"
faces = [1, 2, 3, 4, 5, 6]
labels = ["💀", "2", "3", "4", "5", "6"] # what is printed if no texture
[[die]]
id = "d4"
shape = "tetrahedron"
read = "vertex-up"
faces = [1, 2, 3, 4]
color = "#f0e6d0"
# --- tables (optional, see docs/tables.md) ---------------------------
[[table]]
id = "bone-felt"
name = "Bone felt"
floor_texture = "tables/felt.png"
floor_tiling = [3, 6]
floor_color = "#d9cbb0"
wall_color = "#3a2a18"
sound = "felt"
| Field | Required | Notes |
|---|---|---|
format |
yes | Integer. The app refuses formats newer than it knows. |
set.id |
yes | Slug of 3–40 characters ([a-z0-9-], starting and ending with a letter or digit). Used as the setref in notation and as the folder name, which is why it has a floor. |
set.name, set.version |
yes | |
set.author, license, description, homepage |
no | Displayed only. homepage is shown as text, opened only on explicit tap, https only. |
defaults.* |
no | Material and physics defaults, all clamped. |
die.id |
yes | Slug of 1–40 characters, unique within the set — shorter than a set id, because d2, d4 and d6 are the ids plain notation resolves. Standard names (d2…d100, d10-tens, df) are what typed notation resolves, optionally set-qualified as brass:2d20. A die with any other id is rolled by tapping it in the dice picker — the grammar in docs/dice-notation.md has no unambiguous way to write skull-d6kh1, since a slug and a modifier are made of the same characters. |
die.shape |
yes | A name from the shape catalogue below. v1 has no other option. |
die.faces |
yes | Integer values, one per face (or vertex). Length must match the shape. Range −9999..9999. Duplicates allowed (d2-as-d6). |
die.labels |
no | Strings printed on faces when no texture. Defaults to faces as text. Max 4 characters each. |
die.read |
no | face-up (default) or vertex-up. |
die.texture |
no | Path to a PNG/WebP atlas, relative, inside the set folder. |
die.color, number_color, roughness, metallic, size_mm, density, restitution, friction |
no | Per-die overrides of defaults. |
table.* |
no | Table looks; fields and limits in docs/tables.md. |
The catalogue is closed in v1: these eight shapes and nothing else. Face
count, face order and texture atlas layout are defined by the app (documented
in dicesets/format/shapes/ with reference images).
| Name | Faces | Typical use |
|---|---|---|
coin |
2 | d2 |
tetrahedron |
4 | d4 |
cube |
6 | d6, d2-as-d6 |
octahedron |
8 | d8 |
pentagonal-trapezohedron |
10 | d10, d10-tens (together: d100 / d%) |
dodecahedron |
12 | d12 |
enneagonal-trapezohedron |
18 | d18 |
icosahedron |
20 | d20 |
A set is free to define any number of dice on these shapes, with any face
values and any artwork — a d6 of runes and a d20 of skulls are both just
cube and icosahedron. What v1 does not accept is a new solid: a d30, or
a shape supplied as a mesh. See “Shapes after v1” below.
The atlas layout for a catalogue shape is a fixed grid: face i occupies cell i of an N-cell grid, each cell square, drawn with the face’s “up” direction matching the catalogue’s reference orientation. The face designer produces exactly this layout, so hand-drawn and hand-authored sets are interchangeable.
Up is +z, in the tray, in the physics and in the renderer — one
right-handed coordinate system shared by all three, so nothing has to be
turned over on the way between them. “Up” on a face is that up flattened
onto the face: the part of +z that lies in the face’s plane. A face pointing
straight up or straight down has no such part, and those two are turned by
+y instead. The face’s own circle fills the cell, so a triangle and a
pentagon both touch its edges and a strip of cells is drawn at one size.
The one shape where a cell and a readable position are different things. A tetrahedron is read from the corner pointing up, so its four numbers belong to corners, while its four cells are painted on faces.
Cell i is the face opposite corner i — which is to say, the triangle whose three corners are the three that are not i.
A number is drawn at the corner it belongs to, on every face that meets that corner. So cell i carries three numbers, not one: the values of the three corners other than i, each at its own corner of the triangle. That is what a moulded d4 does, and it is what makes the die readable — when a corner points up, all three faces you can see carry that corner’s number at their apex.
It also means the two faces sharing an edge agree along it: both draw the same value at each end of that edge, because the value belongs to the corner rather than to either face. An author who draws them differently has drawn a die that reads as two different numbers depending on which way you look at it.
Face order. Faces are numbered from the top of the shape’s reference
orientation downwards, and anticlockwise around each ring starting from the
+x side; face 0 is the one that is up when the die has not been turned. That
is a choice rather than a law, but it is a fixed one — it is the order faces
is read in and the order the atlas fills its cells, so changing it would
silently repaint every die of every set ever published. simulation/api owns
it, and the renderer and the physics hull are built from the same arithmetic
rather than from a model somebody exported.
size_mm is the die’s nominal size, quoted the way a dice maker quotes
one: the edge length for a polyhedron, the diameter for the coin. Each shape
has a fixed ratio from that to its bounding-sphere radius, which is what the
table’s capacity rule sums — the ratios are listed in docs/tables.md.
Everything in this section is planned, not implemented. v1 rejects
shape = "mesh" at validation with “unknown shape”; a set that uses it does
not install. The design is written down here so the format does not have to
change shape later, and so nobody has to rediscover the constraints.
Two things are wanted: more catalogue solids (a d30 as a
rhombic-triacontahedron is the obvious first one) and author-supplied
convex meshes. The catalogue is easy — it is one more entry per solid. The
mesh path is the one that needs rules:
vt for texture mapping).face_map
must cover every cluster exactly once.size_mm by longest axis; the centroid becomes the origin.Plus die.mesh (relative path to the OBJ) and die.face_map (OBJ polygon
index → entry in faces; coplanar polygons map to the same face) as extra
fields.
A perfectly legal custom mesh may still be a terrible die — a very flat one lands on two faces and nothing else — and the geometry alone cannot say how unfair it is. So mesh support also needs a fairness preview: roll the die 1,000 times headless on import and show the face histogram, informational only. That preview is part of the same future work; v1 has no dice whose fairness is in question.
Why this is not in v1: every catalogue solid is a fair die whose behaviour is known and tested, and the physics, the atlas layout and the statistics all lean on that. Arbitrary hulls bring convexity checks, degenerate geometry, unfair dice and a fairness UI — a whole feature, not a field.
inJustDecodeBounds); oversized images are rejected without decoding.number_color on top
of the die colour for that face.Users paste a URL. Accepted sources:
| Source | Example | How it is fetched |
|---|---|---|
| GitHub | https://github.com/ada/brass-and-bone…/tree/v1.2.0…/tree/main/sets/skulls |
Ref resolved to a commit SHA via the API, tarball for that SHA |
| GitLab (gitlab.com or self-hosted) | https://gitlab.com/ada/brass-and-bone/-/tree/main |
Same, via the GitLab API |
| Codeberg / Gitea / Forgejo | https://codeberg.org/ada/brass-and-bone |
Same, via the Gitea API |
| Any archive | https://example.org/dice/brass.ziphttps://example.org/dice/brass.tar.gz |
Direct download; the SHA-256 of the archive is recorded in place of a commit SHA |
| Local file | picked via the system file picker | .zip, .tar.gz or a folder |
The forge integrations exist for convenience (browse to a repo, paste the URL, get updates). They are not what makes an install safe — the validator is, and it runs identically for every source; an unknown host is treated as a plain archive and goes through the same extraction and the same checks.
Plain http:// is refused before a request is made. Redirects are followed
by hand rather than by the HTTP client, so that a redirect from https to
http — the oldest downgrade there is — is refused instead of taken; at most
5 hops. Downloads are capped at 64 MiB by the bytes that arrive, not by
the Content-Length the server claims, and time out after 60 s.
Install flow:
.zip, .tar.gz or .tgz..., in either slash direction — an archive
written on Windows carries ..\..\, and a check that only knew about
/ would wave it through to a filesystem that knows both.toml, png, webp, obj, md, txt). Anything else is skipped rather than
refused: a repository is entitled to contain a .gitignore.diceset.toml (at the root or at the given subfolder).dicesets/<set.id>/. If a set
with that id exists, ask to replace (versions are compared). Replacing
moves the old folder aside to <set.id>.replacing first and deletes it
only once the new one is in place, so a failure halfway leaves the old
set installed rather than neither. Every step of that is checked: if the
old folder cannot be moved aside the install stops before writing
anything, and if it cannot be put back after a failure the message says so
rather than claiming the set is untouched. A .replacing folder left by an
interrupted install is cleared by the next one..meta.json with the source URL, SHA, timestamp and validator
output.The app never runs anything from the repository. No scripts, no build steps.
Updates: “Check for updates” re-resolves the ref (forges) or re-downloads
the archive headers and compares checksums (plain URLs); if the identity
differs and the new set.version is higher, offer to reinstall.
Resolving a ref means asking the forge which commit it is at now — GitHub and
Gitea call that hash sha, GitLab calls it id, and Gitea answers with a list
because its endpoint is “the log from here”. The answer is recorded in
.meta.json as commit, beside the archive’s own SHA-256.
Both are needed and neither replaces the other. The checksum is what makes an install reproducible: it is the bytes that actually arrived. It cannot answer “is there something newer”, because a tarball built twice from the same commit need not be byte-identical — the commit answers that.
A forge that cannot be asked, or that answers with something which is not a
commit hash, does not stop an install. What comes back is checked for being
forty or sixty-four hex digits and nothing else in the reply is read; if it is
not one, the set still installs and only the update check is poorer for it. A
forge is a stranger like any other host (SECURITY.md).
The same fetch and extraction path is used for saved-roll collections
(docs/dice-notation.md), which are a single JSON file rather than a folder.
The validator runs the same way for URL installs, local folder/zip imports,
face designer exports and the built-in set. It produces a report of
error / warning lines with file and line references.
Errors (set is rejected):
format, missing required fields, a field of the
wrong kindmesh, which v1 does not implement); faces length
≠ shape face count; a face value outside −9999..9999read, sound or light naming something the app does not havehomepage that is not httpsWarnings (set installs, user sees them):
d12) — notation will fall backEvery error is reported, not only the first: an author fixing a set wants the whole list, and the screen that shows a failed install has room for it (option 6b).
The whole check runs over an abstraction of the package’s files rather than over a folder, so the same code validates a temporary extraction during an install, a zip, the app’s own assets and a test’s memory. A validator that could only read one of those would only ever be tested on one of those.
Dimensions before decoding. A texture’s width and height are read out of
its PNG IHDR or WebP header, in plain Kotlin, before any decoder sees the
bytes — that is what makes refusing a 30,000-pixel image safe, since the
decoder is the part with the attack surface. The same read is what rejects a
truncated download or a .png that is not one. The full decode happens later,
on the IO dispatcher at load time, inside these same caps; a file that
survives the header check but cannot actually be decoded is reported then, and
the die falls back to its label.
The parser is tomlj, a TOML 1.0 reader,
chosen over a hand-written one because parsing TOML is exactly the sort of
thing that should not be hand-rolled (.claude/CLAUDE.md) and over the
alternatives because it reports the line and column of every key it read —
without which the report above could not carry file:line at all. It is used
through its document tree only: every field is asked for by name and checked by
hand into a plain data class. No reflection, no polymorphic deserialization, no
default-on-error, and no deserializer ever sees a downloaded file. Unknown keys
are ignored with a warning to allow future extensions.
examples/ in this repository: a complete, commented dice set
using every catalogue shape, with blank atlases to draw over. Copy the
folder and edit it. (The built-in set cannot be exported from the app —
examples/ is what it would have given you, kept where it can be reviewed
and versioned.)labels for symbol dice (a d6 with a skull on the 1) so the set works
even before you draw textures.