Design: the installed-set list (options 1s and 1t), the set details (6a, and 6b for a failed validation), the “My dice” details with its export (8c), 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.
A worked example of exactly this layout is in the repository at
examples/ — one die on every catalogue shape, a diceset.toml
whose comments say what each field is for and what it is limited to, and a
blank atlas per shape cut to the grid below. It is the place to start, and
Authoring tips says why. Five finished sets to install and look at —
marble with artwork on every face, steel, green resin, red glass, and
Halloween, which glows and has a pumpkin die with artwork on half its faces — are in
sample-sets/, each as a folder and as a zip.
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
translucency = 0 # %, 0 solid .. 100 glass, clamped
restitution = 0.55 # clamped to 0.0..0.8
friction = 0.5 # clamped to 0.1..1.0
edge_rounding = 0.03 # share of size_mm, clamped to 0.015..0.06
glow = 0 # %, 0 none .. 100 glow-in-the-dark at full charge
glow_color = "#e8dcc0" # the colour it glows in; color when not given
# --- 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 = "elongated-square-bipyramid" # the long d4: read from the face on top
faces = [1, 2, 3, 4] # one value per long face
[[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; written 3{skull-d6} or tapped in the picker
shape = "cube"
faces = [1, 2, 3, 4, 5, 6]
labels = ["💀", "2", "3", "4", "5", "6"] # what is printed if no texture
[[die]]
id = "d6"
shape = "cube"
faces = [1, 2, 3, 4, 5, 6]
color = "#f0e6d0" # per-die override
# --- tables (optional, see docs/tables.md) ---------------------------
[[table]]
id = "bone-felt"
name = "Bone felt"
floor_texture = "tables/felt.png"
floor_normal = "tables/felt-normal.png"
floor_tile_mm = 80
color_mode = "average"
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. Notation only reads a setref that starts with a letter (docs/dice-notation.md), so a set whose id starts with a digit installs and rolls from the picker, and a typed formula names it only inside braces, {3dice:d6}. |
set.name, set.version |
yes | |
set.author, license, description, homepage |
no | Displayed only; nothing in the app enforces a licence. license is an SPDX identifier by convention — see “What a licence means”. 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, d4, d6, d8, d10, d10-tens, d12, d18, d20, df) are what typed notation resolves — there is no d100 die, because d100/d% is always a d10-tens and d10 pair (docs/dice-notation.md, “d100 and d%”), optionally set-qualified as brass:2d20. A die with any other id is named in braces — 3{skull-d6}kh1, or 3{brass:skull-d6}kh1 from a named set — because a slug and a modifier are made of the same characters and only the } says where the id ends; the dice picker offers it too and writes the braces for you (docs/dice-notation.md, “A set’s own dice”). |
die.shape |
yes | A name from the shape catalogue below. v1 has no other option. |
die.faces |
yes | Integer values, one per face a value is read from. Length must match the shape. Range −9999..9999. Duplicates allowed (a d6 numbered 1–3 twice, say). |
die.labels |
no | Strings printed on faces when no texture. Defaults to faces as text. Max 4 characters each. |
die.read |
no | face-up, the default and the only value: every die is read from the face on top. vertex-up went with the tetrahedron (decision 109) and is refused. |
die.texture |
no | Path to a PNG/WebP atlas, relative, inside the set folder. |
defaults.translucency, die.translucency |
no | Per cent, 0 solid to 100 glass, clamped. Above 0 the die is drawn as resin: the felt and the dice behind it show through it, bent and tinted by the die’s own color — more of it the higher the value, and wholly at 100 (docs/physics-and-rendering.md, “A die you can see into”). How milky it is, how thick and how it tints what is behind it are worked out from this, color, size_mm and roughness — a low translucency is milky, a high one glassy, and a rough die is frosted — so there are no resin fields of their own. The numerals and any artwork stay fully opaque whatever it is, because a face you cannot read is not a die. |
defaults.edge_rounding, die.edge_rounding |
no | How round the die’s edges and corners are, as a share of size_mm: 0.03 (0.48 mm on a 16 mm die) when not given, clamped to 0.015–0.06 with a warning, refused when it is not a number. The top is measured, not chosen: past 6 % a coin’s rim starts to roll like a wheel and throws stop settling (docs/physics-and-rendering.md, “How round a die may be”; decision 100). It is a physical property, not a look: it is the solver’s convex radius, and the renderer draws the die with exactly the rounding the solver collides (docs/physics-and-rendering.md, “Dice bodies” and “Rounded edges”). Every catalogue solid gets the rounding it asks for, the long d4’s points included (decision 109). Rounder dice tumble a little longer and land a little differently; a fairness run can be asked about any value of it (FairnessTest’s edgeRounding) (docs/architecture.md, decision 94). |
defaults.glow, die.glow |
no | Per cent, 0 (the default) for a die that only reflects light to 100 for glow-in-the-dark at full charge, clamped with a warning. The die gives off light of its own in glow_color, added after the lamps, so the face turned away from them and the side in its own shadow show the glow rather than going dark (docs/physics-and-rendering.md, “A die that glows”). A look, not physics: nothing in the solver reads it. What is printed does not glow — numerals and artwork are paint on the outside of the body, as with translucency — so black ink on a glowing die stays black. A real glow-in-the-dark die is pale, translucent resin, and a set that wants one writes a translucency as well; the two are independent fields (sample-sets/halloween). Lights nothing around it: the felt beside a glowing die is lit by the tray’s lamps alone. |
defaults.glow_color, die.glow_color |
no | The colour a glowing die gives off, #rrggbb. When not given it is the die’s color. A glow-in-the-dark die is pale in daylight and green in the dark, so the two are separate. Refused when it is not a colour, like color. |
die.color, number_color, roughness, metallic, size_mm, density, restitution, friction, glow, glow_color |
no | Per-die overrides of defaults. |
table.* |
no | Table looks; fields and limits in docs/tables.md. |
license is displayed and nothing else. The app does not read it, does not
enforce it and does not refuse a package that has none: it is a statement by
the author to whoever installs the package, and the app’s job is to carry that
statement faithfully and show it where it will be seen (design 6a).
It should be an SPDX identifier — CC-BY-4.0, MIT, GPL-2.0-or-later —
because the point of the field is that the reader recognises what it says.
Where SPDX has no identifier for what the author means, its LicenseRef- form
is the way to say so; the face designer writes
LicenseRef-All-Rights-Reserved for a set somebody is keeping.
A package written by the app itself uses the word unspecified while nobody
has chosen, which is written down rather than left out — a missing field cannot
be told from one an older version never wrote. Nothing the app shares is ever
left at unspecified: the face designer’s export is shut until a licence has
been picked (docs/face-designer.md, “The licence, and why it is a gate”).
Some packages are generated on the device rather than downloaded. “My
dice”, id mine, is built from the drawings in the face designer
(docs/face-designer.md) and from the photographs somebody has made tables
of (docs/tables.md, “Your own photo”). Beside it there can be any number of
named personal sets, made with New set… in the designer’s Save sheet
(docs/face-designer.md, “Save to set”; docs/architecture.md, decision 79):
set.id rule
above; an id anybody on the phone already has — the bundled set, mine, an
installed package, another named set — is refused, never merged.filesDir/personal-sets/<id>/ — its name
(name.txt), its own drafts (drafts/, one file per die) and its own
weight, translucency and size (physical.txt) — and its package in
dicesets/<id>/ like any other. “My dice” keeps the records it has always
had (filesDir/drafts/, filesDir/table-photos/,
filesDir/mine-physical.txt); nothing about it moved when named sets
arrived, so an update loses nothing.<id>.zip; “My dice” is
still my-dice.zip), and its own −/+ steppers on the details screen.color, number_color, roughness, metallic,
translucency and edge_rounding as per-die keys over [defaults] — when
the designer’s Material, Colour or Edges control (its second
step) was used on it, or when it was copied from a set whose die
was not the standard material (docs/face-designer.md, “Material, colour and
edges”). Such a die keeps its own translucency when the details screen’s
stepper moves the rest of the set’s, and the block’s range says so.Every one of them is a folder in dicesets/ like any other, and there is no
privileged path for any of them:
diceset.toml, one
textures/<die-id>.png per drawn die at 256 px per atlas cell with cells
nobody drew on left out so they stay transparent, and one
tables/<table-id>.webp per photo table.mine that is a table pack.dicesets/ and
again before its zip is offered to anybody. A package the app built and could
not install is a bug caught on this phone rather than an install failure on
somebody else’s.mine:d20 resolves
like any other setref.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, in code: the
atlas grid in core/model (ShapeAtlas.kt) and each solid’s faces, their
order and which way each cell is drawn up in simulation/api
(SolidFaces.kt). examples/ carries a blank atlas for every shape to draw
over.
| Name | Faces | Typical use |
|---|---|---|
coin |
2 | d2 |
elongated-square-bipyramid |
4 | d4: the long die (below) |
cube |
6 | 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.
This is the file format’s rule and it does not move: every published set is
painted to it, and changing it would repaint all of them. What does have to
follow it is anything that writes an atlas, and the app’s own face designer
did not — it copied its canvas into the cell square on, so a drawing came out
turned by up to sixty degrees on a d20 and clipped where it ran past the real
polygon. It turns each cell to this rule now (docs/face-designer.md,
“Export details”).
The d4 is a long die: a square prism lying on one of its four long faces,
with a short pyramid on each end (core/model’s LongDieShape). It is read
like every other die, from the face on top, and its four long faces are its
four cells. The ends carry no value and no cell; they are drawn in the die’s
colour and it never rests on them.
flowchart LR
end1["point"] --- body["four long faces, 1 to 4<br/>0.8 wide, 1.4 long"] --- end2["point"]
size_mm: the
points are on the bounding sphere, so the die is size_mm long; it is 0.8
wide, the prism is 1.4 long and each end 0.3, which is 2.5 to 1 overall.LongDieShape.BODY_HALF_LENGTH, held by SolidFacesTest). One found on
an end anyway is cocked and thrown again like any other.shape = "tetrahedron" or read = "vertex-up" is
refused, and told what to write instead: this is a breaking change to the
set format.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 with it which corners make up which face (SolidFaces) — and the
renderer, the physics hull and the face designer’s Solid tab are all built from
that same arithmetic rather than from a model somebody exported.
Opposite faces add up. A moulded die is numbered so that the pair across
from each other sums to one more than the face count: 2 against 5 on a d6, 1
against 20 on a d20, 1 against 12 on a d12. The numbering therefore follows the
pairing rather than the face order — the lowest number goes on the first face
that has not been numbered, the highest on the face across from it, then the
next lowest and the next highest — so the bundled package’s faces lists are
not 1, 2, 3, … down the order.
Which face is across from which is geometry, not a convention: it is read
off the same normals the die is scored from, in simulation/api’s
ShapeGeometry.oppositesOf, and FaceNumbering lays a set of values out along
them. Nothing hand-writes a table per shape, because a table would be a second
description of the face order and the two would come apart the first time
either was touched. The bundled diceset.toml carries the answer written out —
a set file is data and cannot compute anything — and a test in
dicesets/builtin holds it to what the solids say.
It is the same rule for values that are not 1..n. A Fudge die’s
−1, −1, 0, 0, 1, 1 comes out a minus across from a plus and a blank across
from a blank; a tens d10’s 0–90 comes out 0 across from 90.
Every shape in the catalogue has a face across from every face. The long d4’s four long faces are two pairs, so it is 1 across from 4 and 2 across from 3.
A set author is not obliged to follow any of this. faces is whatever the
author writes and the app prints it where the order says; the rule is what the
bundled set does and what the face designer’s “fill all with numbers” starts
from (docs/face-designer.md).
A die that was rolled before this rule is not a die that was rolled after it. The same seed puts the same face up and that face now carries a different number, so a replayed roll and a per-face statistic taken before the change do not compare with ones taken after (
docs/statistics.md).
size_mm is how wide the die is: the diameter of the sphere its corners
sit on, whatever solid it is. A 16 mm d6, a 16 mm d12 and a 16 mm d20 are all
16 mm across at their widest. Half of it is the bounding radius the table’s
capacity rule sums (docs/tables.md).
It is deliberately not the dice maker’s nominal size — the edge length for a
polyhedron — even though that is what a manufacturer quotes. A set author
writing size_mm = 16 means “a 16 mm die”, and should not have to know that a
dodecahedron’s edge is 0.36 of its width to get one. Read as an edge length,
size_mm = 16 makes a d12 that is 45 mm across, and dice that size under
ordinary gravity fall slowly enough to look weightless.
One consequence worth knowing: a d4 and a d20 of the same size_mm have the
same bounding sphere, so the long d4 — as long as the d20 is wide, and
slender — is the smaller solid inside it. That is how a
real set looks — the dice are sized to sit together in a hand, not to enclose
the same volume.
A set author writes size_mm, density and translucency. A player does not
think in any of those, and the design of 2026-09-17 gives the set detail screen
a Physical block that says the same three things in the words a dice shop
uses:
| Shown | Unit | Built-in | Brass & Bone | Nordic runes |
|---|---|---|---|---|
| Weight | g per die | 4.2 | 6.8 | 9.1 |
| Translucency | % | 0 | 18 | 0 |
| Size | % of average | 100 | 108 | 115 |
density. Nobody holds a die and estimates its
grams per cubic centimetre. The gram figure is density × volume, and the
volume is the solid’s. The solver computes one for a body’s mass, out of the
hull it collides, and nothing on the JVM can ask it for one — so the app does
not ask. Every catalogue shape is a known solid, so its volume is a
constant times the cube of the die’s circumradius, and the constants are
derived from the solids themselves in core/model’s DieVolume: 1.024
for the long d4 (a prism and two pyramids), 8/(3√3) for a cube, 4/3 for an octahedron, and so on.
The two trapezohedra have no textbook constant and are still exact — the
solid is cut into an antiprism, whose volume the prismatoid rule gives
exactly, and a pyramid over each ring. A coin is the prism its rim is
collided as, not the cylinder it is drawn as. Because the same circumradius
scales the hull the solver is handed, the grams on the screen are the mass of
the body that is thrown rather than an estimate of it.size_mm and one density are not the
same amount of material (see “Size”). A commonest value would be a claim
about one die printed as though it spoke for the rest.size_mm seen from the other end — 100 %
is the 16 mm the built-in set uses — and it is bounded far more tightly than
the format’s 8–40 mm, because this is a slider somebody drags rather than a
number an author thought about. 50–150 % of 16 mm is 8–24 mm, so everything
the stepper can reach is a size the file format already accepts.clamp(0.7, 1.05 × (4.2 / w)^0.35, 1.45)
seconds is an animation standing in for a solver, and the app has a solver.
A 9 g die settles sooner here because it is heavier, not because a curve says
so.diceset.toml, and
editing them on this phone would make brass mean two different things on
two phones. My dice — and every set named in the face designer — is a
set this phone wrote, so it carries −/+
steppers at 0.1 g, 5 % and 5 %, each reading the live value so a rapid run of
taps accumulates rather than fighting the last frame. The state the finger is
moving is the screen’s; the record is written under it, and the folder
catches up at the next reading.density, because that is what a set
file keeps and what the solver is given. A gram is a fact about one die of
one shape, so a tenth of a gram only means something against a reference
solid, and the reference is a d6 of the set’s current size — the die the
design’s own figures quote and the one die nearly every set defines. A set of
nothing but d20s therefore moves by a little over a tenth of a gram per tap,
and the screen says what its dice actually weigh rather than what the tap was
called. The density stays inside the format’s 0.5–8, so a run of taps stops
at balsa and at brass.filesDir/mine-physical.txt, designer’s PhysicalStore;
a named set’s is filesDir/personal-sets/<id>/physical.txt), never in
dicesets/mine/diceset.toml. That file is a view: the package is
built, not accumulated, so a number written only into it would be
rewritten away by the next stroke somebody drew. The three go out as the
package’s [defaults] table — the one material “My dice” declares, because
it is the one somebody set on purpose — and come back through the ordinary
validator, which is how the details screen reads them again.size_mm
is the width across the corners (“Size”), so a 16 mm d6 here is a 9.2 mm
cube, and the block prints 0.9 g for it. That is not a rounding
disagreement, it is the same disagreement “Size” already settles, and it is
settled the same way: the screen says what the die that is thrown weighs. A
set that wants shop-sized dice writes size_mm = 28, and its d6 then weighs
the 4.9 g a solid acrylic cube that size weighs.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. A face whose cell the atlas draws on is
not printed — it carries the drawing alone (“Labels, and the artwork
over them”).A die’s texture is a path relative to its own set’s folder, so the path
alone names nothing — two packages may both ship textures/d20.png. What the
renderer asks for is therefore a key: the package and the path, written
mine::textures/d20.png (AtlasKey in render/filament). The separator is
:: because a set id is a slug and cannot contain one, and because a path that
arrives with no package in front of it is then refused rather than mistaken for
a package called textures.
flowchart LR
die["Die.texturePath<br/>textures/d20.png"] --> key["AtlasKey.of(setId, path)<br/>mine::textures/d20.png"]
key --> stage["FilamentStage<br/>atlases: (String) -> Texture?"]
stage --> cache["AtlasCache, on FilamentEngine<br/>one Texture per key and file version<br/>misses remembered"]
cache --> app["DieArtwork, in :app<br/>splits the key"]
app --> found["InstalledArtwork<br/>which folder, which file, how big"]
found --> decode["AtlasDecoder<br/>bounds, then pixels, then cells"]
decode --> image["AtlasImage<br/>RGBA8, straight alpha"]
image --> cache
Four things about that path are rules rather than arrangement:
render/filament knows a key and a
(String) -> Texture?; where a package lives and how a PNG becomes pixels are
dicesets/install’s, joined in :app — the same seam RollWiring is
(docs/architecture.md, decision 40).FilamentEngine, beside the material compiled on the device, and
destroyed with it. A Texture is a native handle, so one uploaded per throw
and dropped is a leak nothing on the JVM can see. A key that came back empty
is remembered as empty too, so a file that will not decode is read once.
Once per version of the file, not once for ever. The face designer
rewrites “My dice” under the same id and the same paths every time Roll it
is pressed, so each entry also remembers the file’s size and modification
time (InstalledArtwork.stamp, a stat and no read). When those change, the
atlas is decoded again and the old Texture is given back, which is safe
because a package only changes while the roll screen is not showing. Before
this, a drawing rolled a second time was thrown as it looked the first time.docs/physics-and-rendering.md, “Rendering (normal mode)”; decision 101).AtlasImage.drawnCells, the
complement of the validator’s empty cells), for every face count the
catalogue has, and that answer travels with the texture
(AtlasCoverage). It is what decides which faces are printed (below).A table look’s pictures go through this too. The validator stamps every
look with the package it read it out of, so a floor’s picture is keyed exactly
like a die’s atlas — builtin::tables/felt-albedo.webp — and goes through the
same path, size and decode checks on the way. Two things differ, both on the
GPU side: a table’s pictures are uploaded with every mip level and sampled
anisotropically, because a floor is looked at along it, and the colour
picture is uploaded as sRGB (docs/tables.md, “Textures”). The bundled
package is answered from its resources, the files it was validated from,
because it is never installed to disk; its id is matched before any folder’s,
as SetLibrary matches it.
A face carries its drawing or its label, never both. The labels go on in
number_color on the body colour, in the same atlas grid an image fills, and
the atlas is composited over that by its own alpha — but only the faces the
atlas leaves clear are printed. A cell with anything drawn in it (any pixel
above AtlasImage.CLEAR_ALPHA) carries the drawing on the body colour and no
label; a cell left wholly clear carries the label. That is what “an atlas may
leave cells transparent” above means, and it is settled per face, from the
decoded pixels, by the renderer (render/filament’s AtlasCoverage and
PrintedDice; docs/physics-and-rendering.md, “Rendering”;
docs/architecture.md, decision 96). A die with no texture at all is simply
the case where every cell is clear.
It was settled per pixel until v0.2.x — every face printed, the artwork laid
over the printing — and that showed the label through every clear part of a
drawing: a skull drawn in outline on a d6’s 1 had a 1 in the middle of it,
and every face drawn in the face designer came to the tray with the set’s
number over it. A number nobody drew is not part of anybody’s artwork. An
author who wants the number and a border draws both, which the face
designer’s “fill all with numbers” does in one tap (docs/face-designer.md).
The decoder says which face cells came out empty, as a warning, because the other way to arrive at an empty cell is an atlas saved in the wrong grid and the two look identical from outside (“Validation”, below).
Three rules decide what a face ends up carrying when it is printed:
| The label | What is printed | Why |
|---|---|---|
something the built-in font can draw — digits, +, −, ×, %, . |
the label | it is what the author wrote |
something it cannot, such as 💀 |
the face’s value | a row of blanks would make the die unreadable, and a box would be a lie about what the author wrote. The value is the one thing about a face the app can always write down, and it is what the player is about to read off it anyway |
| empty | nothing | a blank side is a face an author asked for, and half a Fudge die is exactly that |
A value printed as a label is written with a typographic minus, U+2212,
and not with the hyphen on a keyboard. That is the character the bundled set
labels its Fudge die with, the one the notation help writes −1, 0 or +1
with, and the one the built-in font draws at the width of the + on the next
face of the same die; a hyphen is a word-joiner, drawn short, high and thin,
and it does not read as the other half of a pair. It applies in the two places
the app chooses the text itself — a faces entry with no labels beside it,
and the fallback in the table above — and nowhere else. labels is printed
exactly as the author wrote it, hyphen and all: the app does not correct
somebody’s typography, and Face.printed is a default rather than a filter.
A number is marked when it could be read as another number on the same die.
Turn the label about; if what comes out is a different label this die also
carries, both are marked. That is why a d20’s 6 and 9 are and a d6’s 6 is
not — a d6 has no 9 for its 6 to be mistaken for, which is exactly what a
moulded d6 does. An 8 turns into itself and a 2 turns into nothing
readable, so neither is ever marked.
The mark is a trailing dot, 6. and 9., which is the design’s
(design/dInfinityPhone.dc.html, 2026-09-17) and which replaces the bar
underneath this rule used to draw. A bar under a numeral is a second horizontal
in a system whose dice already have edges; a dot is the one mark that cannot be
confused with the die.
The derivation is what makes the percentile pair come out right without a rule
of its own: a d%’s units digit is 6 against 9 and is dotted, while its tens
die carries 00–90, none of which turns into another of them, so the tens
are never dotted. A number set upright in the result sheet is not marked
either, and for the same reason — nothing there is lying at an angle.
The font is not the set’s to choose. It is one built-in face, cut from Archivo
(docs/assets/README.md), and a set that wants its own lettering draws it and
ships it as a texture — which is what a texture is for.
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 | A single .zip, .tar.gz or .tgz file. A folder cannot be picked: the picker opens one document, so a set on the phone’s storage is zipped first. |
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. The
allowlist, the size cap and the entry count are the caller’s to name, so
that a saved-roll collection can come down this same path under bounds of
its own (below).diceset.toml (at the root or at the given subfolder). Which file
marks the package is the caller’s to name as well; everything before this
step is identical whatever is being unpacked.dicesets/<set.id>/. If a set
with that id exists it is replaced without asking — versions are not
compared, so installing an older package over a newer one is allowed — and
the result says so afterwards (“… was replaced with this one”). 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 and compares the commit with the one recorded at install. A set whose forge has moved on is badged on the list — the filled accent tag, which is the one of the three badges that says something to do rather than something that is — and updating it is a re-install from the source the install recorded — through the same validator, over the top of the folder that is there. An update that is refused costs nothing, because a package that fails validation leaves the one already installed alone.
A plain archive is checked too, by asking its server about the file. It has
no commits to tell apart, so the install records what the server said — its
ETag, and the Last-Modified beside it — and the check is a HEAD request
comparing the two. A HEAD, because the whole point is to avoid pulling sixty
megabytes to find out that nothing changed.
Neither header is parsed. An ETag is an opaque string by definition, and the
date beside it is a header to compare rather than a time to reason about:
deciding that one date is later than another would read a meaning into a
server’s clock that nobody promised. The ETag is preferred where both ends
have one, because it is the server’s own statement about the bytes, where a
date changes when a file is rebuilt without changing.
Like is compared with like, and nothing is said otherwise. A set installed
with an ETag, against a server that has since stopped sending one, is
unanswerable rather than outdated — badging it would send somebody to
re-download a set that has not changed. The same is true of a set installed
before any of this was recorded: its note has a source and nothing to compare,
and re-installing it is the only honest way to give it one.
Which of the two questions a set gets is decided by what its install recorded, not by reading the link again: a set with a commit against it came from a forge, and anything else is an archive. Re-parsing the URL would be a second place for “is this a forge” to be answered, and two answers to that is one too many.
A set installed from a file has no source at all and is never asked.
A download says how far it has got and can be stopped (design 9i). The
bar is drawn from bytes that have actually arrived; the Content-Length beside
them is what the server said and is only ever used to decide how full to draw
it, so a server that sends none gets an indeterminate bar rather than a wrong
one, and one that lies cannot push it past full. The cap is applied to what
arrives, never to the claim.
The bar is there only while something is coming down the wire. An install from a file on the phone has nothing to show, and neither has the validation after a download — a bar that reached full and sat there would say the app had hung at the moment it was working hardest.
Cancel stops the download and nothing else. Once the archive is on disk the install is a validator over a folder and finishes in a moment, and a half-installed package is exactly what the install order above exists to make impossible — so there is nothing left to interrupt that would be safe to. A stopped download throws its half-written file away where it was being written, and the screen says nothing about it: the person who pressed Cancel knows what happened, and answering them with an error message is arguing with them.
The button is not drawn at all when nothing could be asked, and what a check found is said in a line rather than only on the rows: a check that found everything current and a check that could not reach anything look identical on a list where nothing is badged either way. A forge that cannot be reached is counted as unreachable rather than quietly read as up to date.
The button is still not drawn when nothing could be asked, and a set with nothing to compare is skipped rather than asked a question whose only possible answer is “no idea”.
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.
A repository of them is recognised here, by the same table of forges above, and
fetched and unpacked by the same code under the same refusals. Two things are
named differently and nothing else is:
| Named by the caller | Dice set | Saved-roll collection |
|---|---|---|
| What marks it | diceset.toml, anywhere in the archive or at the named subfolder |
one *.dinfinity.json, at the repository root |
| What may be written | toml, png, webp, obj, md, txt |
json |
| What it may expand to | 64 MiB | 1 MiB, the size a collection may itself be |
| What is kept afterwards | the folder, under dicesets/<id>/ |
nothing; the rolls go into the database and the unpacked folder is deleted |
The marker file is the same idea in both: a package says what it is by a name everybody agrees on, at a place everybody can find. A collection’s is at the root and there may be only one, because a link names a repository rather than a file — an import that quietly chose between two would be choosing for somebody.
Five finished sets come with the app and are installed on its first launch:
marble, steel, green resin, red glass and Halloween, the archives in
sample-sets/ (decision 107). Each defines the ten standard
dice, so they show resin, glass, metal and glow on every shape from the first
roll: steel:3d6, {red-glass:d20}. Halloween also shows what a set’s own
die can be: pumpkin is a cube with artwork on three faces and the three
opposite left blank, worth one and nought, so 6{halloween:pumpkin} counts
pumpkins rather than adding pips. Its blank cells are clear on purpose, so it
is the set that shows AtlasCellsEmpty meant.
They are installed, not bundled. The app carries their zips and installs each
one through the same installer and validator as a file you pick (design 1s
shows them among the installed sets). After that they are yours like any other
set: switched off, or removed. Only Built-in is bundled and can’t be removed.
flowchart TD
launch["first launch of this install or update"] --> seen{"installed by the app before?"}
seen -- no --> have{"you already have it, same version or newer?"}
have -- no --> install["installed through the validator"]
have -- yes --> keep["left alone, noted as seen"]
seen -- yes --> there{"still installed?"}
there -- "no: you removed it" --> gone["stays removed"]
there -- yes --> newer{"the app's version newer?"}
newer -- yes --> install
newer -- no --> keep2["left alone"]
steel:3d6 resolves against, follow every change
to the installed sets. Dice in the air keep the sets they were thrown
with until they land.preinstalled-sets.txt
(docs/architecture.md, “Storage layout”). An installed sample set’s
source is app:<name>.zip.The validator runs the same way for URL installs, local archive 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, light or color_mode naming something the app does not havehomepage that is not httpsWarnings (set installs, user sees them):
d12) — notation will fall backTwo of the checks need a decoder, so they are not in either list above:
they cannot be answered from bytes alone and are raised where a texture is
first decoded, at load time, by AtlasDecoder in dicesets/install. They
produce the same ValidationMessage lines as everything else.
| Code | Severity | What it means |
|---|---|---|
TextureWillNotDecode |
error | The header check passed and the pixels are not there — a truncated download, a corrupt file, or a .png whose IHDR is the only sound thing about it. The die falls back to its printed labels and the package is otherwise untouched |
AtlasCellsEmpty |
warning | The atlas leaves one or more of a die’s face cells undrawn, so those faces are printed instead. Legal, and often meant; it is said because an atlas saved at the wrong size or in the wrong grid looks exactly the same from outside |
Neither can refuse an install, and that is deliberate: a package is installed from its files and validated from its header, and a picture that goes bad on disk afterwards is a die that prints its labels rather than a set that stops working.
Every 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.
setref) that would have taken a die from it falls
back per die to the built-in set, and the breakdown says which set each die
came from. Notation that names it with a setref gets no fallback: it is a
parse error naming the missing set (docs/dice-notation.md).InstalledSets answers with a package that is
either ready or broken-with-its-report, and the two cases exist precisely
because passing the validator once does not make a folder valid for ever. A
broken package keeps its folder: an update is the way out of that state
and an update needs somewhere to update from (design 6b).6a). d20 with no set in front of it then means that set’s d20,
falling back to the bundled set per die for anything it does not define
(docs/dice-notation.md). A set that is switched off or will not load is not
offered the job, and one that stops being usable after being given it falls
back without the setting being rewritten.SetLibrary builds
the catalogue every time it reads the dicesets/ folder, from the bundled set
plus every installed package that is on and still validates — the same test
a row’s status shows. Reading the folder and deciding what a d20 means are
the same facts, and keeping them apart is how a set comes to be listed as
installed and still not roll.InstallLimits.MAX_DOWNLOAD_BYTES — the same cap a download gets, because
the limit is about what the extractor is willing to open rather than about
where the bytes came from. The copy is deleted however the install ends.1t).6b). Each
line carries file:line, because the person who can fix it is the author.https is ever handed to the system, and the check happens where the intent
is started — the string came off a file on disk.5a).installed_set holds one row per package the player has had an opinion
about, and a package with no row is enabled — which is what a set does the
moment it installs (docs/statistics.md, “Storage”). Disabling keeps the
folder, the .meta.json and every statistic recorded against the set’s dice,
which is what makes it a reversible decision and why the screen offers both
it and removal (design 5a).docs/dice-notation.md), and unlike every other set there would be no way
to install it back.diceset.toml gives an id other than the folder’s own name is
refused. Notation resolves a set by folder, so the dice it handed out would
come from a set the player never named — and the app cannot have produced it,
because an install names the folder after the id the validator accepted.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.)tools/generate-atlases.py, and checked
against the catalogue by a test in dicesets/format: each is fully
transparent, at 256 px per cell, in the grid the shape’s face count gives.
That is the size worth copying — an atlas in the wrong grid validates,
installs, and comes out sliced down the middle of every face.docs/face-designer.md).labels for symbol dice (a d6 with a skull on the 1) so the set works
even before you draw textures.