This log records decisions that materially affect architecture, public contracts, compatibility, or contributor workflow. New records use the next sequential identifier. Do not edit accepted historical context; supersede it with a new record when direction changes.
- Date: 2026-07-25
- Status: Accepted
- Decision: The core resolves dice rolls and coin flips without importing rendering, physics, game-engine, UI, or networking dependencies.
- Rationale: A headless core enables web, server, Unity, Godot, test, and simulation use cases while keeping integration lightweight.
- Consequences: Visual adapters must consume core event records rather than decide outcomes. Additional contracts are needed between core and presentation layers.
- Alternatives considered: A browser-first Three.js implementation; separate implementations per platform.
- Date: 2026-07-25
- Status: Accepted
- Decision: Local functionality requires no account, service, or network connection. Multiplayer/synchronization is an optional plugin category.
- Rationale: This keeps the SDK useful in games, prototypes, private tools, and disconnected environments.
- Consequences: Networked features must be additive and cannot be part of core correctness.
- Alternatives considered: A hosted synchronized dice service as a required backend.
- Date: 2026-07-25
- Status: Accepted
- Decision: The initial SDK supports dice and coin flips only. Other tabletop interaction types are out of scope unless separately adopted.
- Rationale: A focused domain gives the project a coherent first release and protects integration quality.
- Consequences: Plugin architecture remains general, but no speculative support for cards, tiles, or spinners is built initially.
- Alternatives considered: A broad tabletop interaction engine from the outset.
- Date: 2026-07-25
- Status: Accepted
- Decision: The TypeScript monorepo uses npm workspaces (no separate monorepo task runner), Vitest for tests and coverage, Biome for linting and formatting, and plain
tscproducing ESM-only output with type declarations. Supported runtime is Node.js >= 20 for tooling and any ES2022 JavaScript environment for the core package. - Rationale: npm ships with Node, keeps contributor setup to
npm ci, and its publish workflow is a plainnpm publish. Unity and Godot adapters will not be npm packages, so a heavier monorepo tool buys nothing today. Vitest and Biome minimize configuration and dependencies while covering tests, coverage, lint, and format. ESM-only output matches modern bundlers and Node without dual-package complexity. - Consequences: Contributors need no global tooling beyond Node and npm. If the workspace later gains many interdependent packages, a task runner (or pnpm) can be adopted via a superseding ADR. CommonJS consumers must use dynamic
import()or a bundler. - Alternatives considered: pnpm workspaces (stricter isolation but extra install step); Turborepo/Nx (premature for one package); ESLint + Prettier (more configuration and dependencies); dual CJS/ESM builds (complexity without a current consumer).
- Date: 2026-07-25
- Status: Accepted
- Decision: The seeded random source hashes the seed text with cyrb128 into the state of a xoshiro128** generator, implemented with 32-bit integer operations only. Guarantee: the same seed produces the same sequence on every platform and every core release. Golden known-answer tests lock the sequences; changing the algorithm or constants is a breaking change requiring a superseding ADR. Die faces are derived from
nextUint32()via rejection sampling so no face is biased. The system (non-seeded) source uses Web CryptogetRandomValueswhen present, falling back toMath.random, and is explicitly non-reproducible. - Rationale: xoshiro128** is a public-domain, well-studied generator that is fast and exactly reproducible in JavaScript's 32-bit integer semantics, unlike float-based approaches. cyrb128 turns human-friendly string seeds ("table-42") into well-mixed state. Rejection sampling removes modulo bias without floating-point involvement.
- Consequences: Replays, tests, and cross-device synchronization can rely on identical outcomes from identical seeds. Cryptographic unpredictability is explicitly not guaranteed for seeded sequences; provenance metadata records which source produced each result. Verifiable fairness remains future scope.
- Alternatives considered:
Math.randomwith no seeding (not reproducible); PCG32 (needs 64-bit emulation in JS); Mersenne Twister (large state, slower); float-based mulberry32 pipelines (risk of cross-engine drift).
- Date: 2026-07-25
- Status: Accepted
- Decision: Notation grammar v1 is
[sign] term { ("+"|"-") term }where a term is an integer modifier or a dice group[count]d(sides|%)with optionalkh/kl/dh/dlselection (count defaults to 1). It is case-insensitive and whitespace-tolerant;d%means d100; sides are restricted to {4, 6, 8, 10, 12, 20, 100}. Limits: 100 dice per group, 20 terms, modifiers up to 1,000,000, 500-character expressions, and at least one dice group per expression. Event records (RollResult,CoinFlipResult) carryschemaVersion: 1, are deeply frozen, preserve per-die rolled order withkeptflags, and embed RNG provenance. Keep/drop ties are broken in favor of earlier-rolled dice. Serialization is canonical JSON; deserialization validates structure and internal consistency (subtotals and totals recomputed), drops unknown fields, and rejects unknown schema versions with a dedicated error code. Additive optional fields keep the version; renaming, removing, or re-meaning fields bumpsschemaVersionwith documented migration. - Rationale: A small, unambiguous grammar covers the dominant tabletop cases (modifiers, advantage/disadvantage via
2d20kh1/2d20kl1, ability-score4d6dl1, percentile) without committing to a full expression language. Consistency validation makes deserialized records trustworthy inputs for presenters and replay. Explicit limits bound memory and keep records renderable. - Consequences: Exotic notation (exploding dice, rerolls, custom dice) requires grammar extensions in the 0.3.0 plugin/extension milestone, not silent core growth. Old cores reject records from future schema versions cleanly rather than mis-rendering them.
- Alternatives considered: Adopting a full existing dice-expression language (large surface, licensing/compatibility risk); arbitrary die sizes in v1 (blocks curated presentation and asset mapping); mutable result objects (invites presentation-layer outcome tampering).
- Date: 2026-07-25
- Status: Accepted
- Decision: The first renderer is
@diceforge/renderer-web: Three.js (MIT) renders 3D dice, and a procedural tumble animation is constructed backward from the core-resolved outcome so every die always lands showing its recorded face. No physics engine is added; physics-based presentation remains a future plugin category. The package serves as both the first renderer plugin and the browser adapter until a split is justified. It ships graceful fallback tiers: WebGL 3D → DOM/2D rendering when WebGL is unavailable → instant results plus text announcements under reduced motion, with aria-live announcements always available. - Rationale: Animating toward a known outcome honors the architecture rule that presentation never decides results, by construction rather than by correction. Skipping a physics dependency keeps the first web integration small, deterministic to verify, and light for adopters. Three.js is the most widely adopted MIT web 3D library, kept strictly internal to the package (no Three types in public contracts).
- Consequences: Dice motion is stylized rather than physically simulated; a future physics presenter plugin can offer realism behind the same
InteractionPresentercontract. Splitting adapter and renderer into separate packages later requires only package reshuffling, not contract changes. - Alternatives considered: cannon-es/Rapier physics with final-orientation correction (heavier, corrective rather than constructive); Babylon.js (larger engine footprint); CSS/2D-only presentation (defers the SDK's 3D promise).
- Date: 2026-07-25
- Status: Accepted
- Decision:
InteractionPresenter,PresentationOptions, andAbortSignalLikeare defined in@diceforge/coreas pure type exports (packages/core/src/presentation.ts). The core declares a structuralAbortSignalLikeinstead of referencing the DOMAbortSignalso its type surface stays platform-free. A dedicated@diceforge/plugin-contractspackage is created only when multiple plugin categories (physics, themes, audio, transport) need shared contracts. - Rationale: ARCHITECTURE.md places plugin contracts behind core-defined interfaces. A single small interface does not justify a new package (per the "no empty packages" rule), and type-only exports add zero runtime weight or dependencies to the core.
- Consequences: Renderer packages depend on
@diceforge/corefor the contract, which they already need for event record types. If contracts grow, moving them to@diceforge/plugin-contractsis a re-export away and will be recorded in a superseding ADR. - Alternatives considered: A
plugin-contractspackage now (premature); defining the contract in each renderer (fragments the ecosystem); referencing DOMAbortSignaldirectly (drags platform libs into the core's types).
- Date: 2026-07-25
- Status: Accepted
- Decision: Packages publish under the npm scope
@diceforge-sdk(@diceforge-sdk/core,@diceforge-sdk/renderer-web) because thediceforgeorg name was already taken when the org was registered. All packages are public (publishConfig.access: "public") and version-locked: they release together with the same version number. Releases are driven by version tags (v*): a GitHub Actions release workflow re-runs every quality gate and publishes both packages with npm provenance. The first publish of each package happens from a maintainer machine (npm requires interactive 2FA before automation exists); afterwards, npm trusted publishing (OIDC) is configured so CI publishes without long-lived tokens. Earlier documents referencing@diceforge/...describe the same packages under their pre-publish working name. - Rationale: The scope mirrors the project name (DiceForge SDK) while staying available. Version-locking sidesteps a compatibility matrix while both packages are pre-1.0 and co-developed. Tag-driven CI releases keep publishes reproducible and reviewed; provenance links every artifact to its source commit and workflow.
- Consequences: A version bump releases both packages even if one is unchanged — acceptable at this stage, revisit via ADR if the package count grows. Renaming the scope later would be a breaking change for consumers and would require a superseding ADR with migration notes.
- Alternatives considered: Unscoped names like
diceforge-core(no namespace ownership, squat-prone); a different scope such as@diceforgejs(weaker match to the project name); independent per-package versioning (premature bookkeeping); publishing manually forever (unreproducible, no provenance).
- Date: 2026-07-25
- Status: Accepted
- Decision: A
DiceThemeis plain data — colors plus an optionalDieModelSetof glTF URLs and a calibrated face-rotation table per shape. Themes never ship binary assets: published npm packages contain code only, and asset files live in the repository'sassets/directory, served by the host application at abaseUrlthe theme is given. A model is used only when its shape has both a URL and a complete rotation table (hasCalibratedModel); otherwise, and on any load failure, that die falls back to the built-in procedural geometry. Third-party assets require a license permitting redistribution, recorded inassets/LICENSES.mdwith author, source URL, retrieval date, and any conditions. The first bundled theme uses KayKit Board Game Bits (CC0, Kay Lousberg). - Rationale: Keeping assets out of the tarball keeps installs small and licensing auditable, and lets applications host, cache, or CDN their art as they choose. Requiring a calibrated table is what preserves architecture rule 5: a model may only present an outcome when we can prove which orientation shows which value, so presentation can never imply a face the core did not resolve. Per-shape granularity lets a partial pack (KayKit has no d10 or d12) coexist with procedural dice in the same roll.
- Consequences: Theme authors must calibrate any new model set; the maintainer tool at
examples/web-demo/calibrate.htmlderives the tables and re-renders from the shipped table to verify them, and unit tests assert each table maps every value to a distinct upward direction. Applications must serve the asset directory themselves — documented in the renderer README. Asset-bearing themes cannot be installed withnpm installalone; if that becomes a burden, a separate opt-in asset package would need its own ADR. - Alternatives considered: Bundling models into
@diceforge-sdk/renderer-web(bloats every install, entangles code and art licensing); auto-detecting face orientation at load time (unreliable — numerals live in textures, and a wrong guess would misreport an outcome); a texture-only theming system (cannot express real dice shapes); refusing to render shapes the pack lacks (worse than mixed presentation).
- Date: 2026-07-26
- Status: Accepted
- Decision: The project authors its own die set — d4, d6, d8, d10, d12, d20 and a two-faced coin — with a committed, headless Blender script (
tools/blender/build_dice.py) that outputs the die models plus aface-rotations.jsonmanifest. The solids are built in Python from the same math aspackages/renderer-web/src/math/geometry.ts; a "DiceForge Finish" geometry node group normalizes size; a Bevel modifier rounds edges. Face values are assigned so opposite faces sum to N+1, which makes the face-up rotation table exact by construction rather than measured. Generated models are MIT, like the rest of the repository, and live inpackages/assets-forge/forge/(originallyassets/forge/; moved by ADR-0013) to stay separate from third-party packs. Blender is a maintainer-only dependency: the generated artifacts are committed, so building or consuming the SDK never requires it. - Rationale: The KayKit pack (ADR-0010) has no d10 or d12 and no coin, so a themed roll could never cover everything the core resolves. Owning the geometry also removes the manual calibration step that third-party models require — the single largest source of error in theming, since a wrong table silently misreports an outcome. Reusing the renderer's tested solid math means the models and the built-in procedural dice are the same shapes. A pure geometry-node graph was not possible: Blender 5.1 has no bevel node and no way to author arbitrary faces, so a dodecahedron and the d10's pentagonal trapezohedron cannot be built in nodes.
- Consequences: Contributors who want to change the geometry need Blender 5.1+; everyone else consumes the committed
.glbfiles. The manifest's UV atlas and per-facefitvalues define the contract a texture generator must follow, which is the next step before aforgeTheme()can ship. Regenerating changes binary assets, so geometry changes should be deliberate and reviewed. If Blender later gains the missing nodes, the script can move further into the node group without changing any output contract. - Alternatives considered: Commissioning or sourcing another third-party pack (same calibration risk, another license to track, still may not cover every shape); modelling by hand in the Blender GUI (not reproducible, not reviewable in a diff); generating meshes at runtime in the renderer instead of shipping models (that is what the procedural dice already do — the point here is higher-quality art); a pure geometry-node graph (impossible for d10/d12, as verified above).
- Date: 2026-07-26
- Status: Accepted (supersedes the fallback behaviour in ADR-0007 and ADR-0010)
- Decision: The WebGL backend no longer generates dice meshes at runtime, and the vendored KayKit pack is removed. 3D presentation now requires a theme whose models cover the roll. When there is no theme, a shape it does not cover, or an asset that fails to load, the presenter falls back to the DOM tile backend for the whole event rather than mixing art styles.
createDicePresenter({ container })with no theme reportsmode: "dom".kayKitTheme, the KayKit rotation tables, and the procedural mesh, label-texture and face-triangulation code are deleted, along withassets/*.gltf|bin|png(about 1.1 MB). - Rationale: The first-party set (ADR-0011) covers every shape the core resolves plus a coin, so the procedural dice were no longer a fallback for uncovered shapes — only a second, visibly poorer art style to maintain, and the source of a run of presentation bugs (inverted winding, mismatched sizes, labels off-face). KayKit was likewise superseded: it never covered the d10, d12 or coin, and keeping two packs meant two calibration stories and a third-party licence to track. Removing both deletes roughly 700 lines of renderer and test code whose only job was to look worse than the models.
- Consequences: A consumer who installs the package and passes no theme gets 2D tiles, not 3D. That is a real reduction in the out-of-the-box experience and follows directly from ADR-0010 keeping art out of the npm package: the two decisions should be revisited together if adoption friction shows up. Themes are now load-bearing rather than optional decoration, and a theme that covers only some shapes downgrades the whole roll to tiles. The DOM backend keeps a working zero-asset path, so no configuration is ever unable to show a result.
- Alternatives considered: Keeping the procedural dice as a no-asset 3D default (preserves the out-of-the-box experience, but means maintaining two art paths indefinitely and shipping a look the project is not happy with); keeping KayKit as a second theme (no coverage benefit now, ongoing licence and calibration cost); rendering uncovered shapes as untextured solids (a die with no numerals cannot show its resolved value, which architecture rule 5 forbids).
- Date: 2026-07-26
- Status: Accepted (amends ADR-0010; relaxes the consequence recorded in ADR-0012)
- Decision: The first-party die set is published as
@diceforge-sdk/assets-forge, a separate, optional package that carries the.glbmodels, the texture atlases, and the generator'sface-rotations.json.packages/assets-forge/forge/is now the canonical home of that art — the Blender pipeline writes there, and the repository'sassets/directory keeps only the licensing record. The package has no dependencies and no renderer code: it exportsforgeAssets({ color }), whose URLs come from literalnew URL("...", import.meta.url)expressions so that Vite, webpack and Rollup emit the files and rewrite the paths.forgeTheme()in@diceforge-sdk/renderer-webaccepts either those URLs ({ urls, color }) or a directory the application serves itself ({ baseUrl, color }); the two produce identical themes. The code packages still bundle no art, and the renderer does not depend on the asset package — it matches the URL shape structurally, and a test in the asset package fails if the two drift apart. The asset package joins the version-locked release train (ADR-0009). - Rationale: ADR-0010 kept art out of the tarball and named the cost it accepted: "Asset-bearing themes cannot be installed with
npm installalone; if that becomes a burden, a separate opt-in asset package would need its own ADR." ADR-0012 then made themes load-bearing — without one there is no 3D at all — which turned that cost into the first thing a new user hits: install both packages, then discover the dice must be copied out of a Git repository by hand. A separate package removes the copying without reintroducing what ADR-0010 was protecting: installs ofcoreandrenderer-webare unchanged, and art and code stay separately licensable and separately versioned. Per-file URLs rather than a base directory are what make it work under a bundler, which hashes and relocates each file; a single directory URL would resolve correctly in dev and silently break in a production build. - Consequences: The default 3D path is now
npm install @diceforge-sdk/assets-forgeandforgeTheme(forgeAssets({ color }));baseUrlremains fully supported for apps that serve their own copy, and is still the only option for a custom pack. A bundler emits every colour's atlas (~1.6 MB total) because the URL table is static — an application that needs less can import single files through the./forge/*subpath or serve the directory itself. Releases now publish three packages at one version, and the first publish of the new name has to come from a maintainer machine, since npm cannot attach a trusted publisher to a package that does not exist yet. Third-party art is unaffected: ADR-0010's licensing rules still govern anything not first-party, and nothing may be bundled intocoreorrenderer-web. - Alternatives considered: Leaving assets repository-only (keeps the install story broken for the common case, which is what ADR-0012 made worse); bundling the art into
@diceforge-sdk/renderer-web(every consumer pays for art they may not use, and entangles code and art licensing — the option ADR-0010 rejected, and still rejected); hosting the set on a CDN and defaultingbaseUrlto it (an offline-first SDK that silently phones out is a contradiction, ADR-0002); shipping a copy CLI instead of URL exports (adds a build step to every project and does not help bundler users); exporting one directory URL instead of per-file URLs (works in development, then breaks in production builds — the failure mode is a 404 at roll time, which is the worst possible moment).
- Date: 2026-07-27
- Status: Accepted (extends ADR-0008)
- Decision:
InteractionPresentergains a requiredcapabilities: PresenterCapabilities— stable domain data describing the event kinds it accepts, the die sizes it can show, the media it may use ("3d" | "2d" | "none", richest first), and whether it cancels, announces, and honors reduced motion. Capabilities describe an instance, not a package: the same renderer with and without a 3D theme reports different media. The core also gainspresentationSupport(capabilities, event), a pure function returning{ supported: true }or a reason ("unsupported-kind","unsupported-die-sides") plus the offending sizes. Declared support is a floor, not a promise about a particular frame: a presenter may still degrade one presentation to a simpler medium — as@diceforge-sdk/renderer-webdoes when a theme cannot cover a roll — provided it shows the resolved outcome.packages/core/src/presentation.tsis therefore no longer type-only; it holds one pure function over domain data and still imports nothing outside the core. - Rationale: ADR-0008 gave presenters a lifecycle but no way to be asked what they do, so applications reach for implementation details instead — reading
presenter.mode, a string only the web renderer defines, or probing WebGL themselves. That works while there is exactly one renderer and stops working the moment there are two, which is the premise of the Unity and Godot adapters on the roadmap. Declaring capabilities as data (rather than as methods to call, or as a version number to compare) keeps them serializable, loggable, comparable between instances, and checkable without constructing anything. Putting the check in the core rather than in each renderer means one definition of "can this presenter show this roll" for applications, adapters, and the conformance suite to share — and it is exactly the kind of pure, platform-free logic the core exists to hold. - Consequences: Implementing
InteractionPresenternow requires declaring capabilities; this is a breaking change to the contract, made while the only implementation is first-party and the API is documented as pre-1.0 experimental.DicePresenter.modestays for browser-specific code but is now the vendor spelling ofcapabilities.media. A declaration can drift from behavior, which is a real risk with no compiler check behind it — the renderer's tests assert the two agree, and the planned conformance kit will make that assertion reusable by third parties. Capabilities are a fixed record: a presenter that gains an ability at runtime (a theme loaded later) would need a new instance or a superseding ADR that makes them observable. - Alternatives considered: Leaving discovery to feature-detection (what exists today: every renderer becomes a special case in application code); methods such as
canPresent(event)on the presenter (harder to log, serialize or compare, and forces a live instance to answer a question about a configuration); a capability version or profile name like"renderer-web@1"(compact but opaque — a consumer cannot reason about a profile it has never heard of); per-event negotiation where the presenter returns what it did after the fact (useful telemetry, but too late to make a decision with, and it would invite treating presentation as authoritative); putting the check in each renderer (guarantees divergence in the one place — support semantics — where divergence is most damaging).
- Date: 2026-07-27
- Status: Accepted (extends ADR-0006; supersedes its fixed die-size list)
- Decision: A die is now defined by its faces.
defineDie({ id, faces })produces a frozenDieDefinitionwhose faces each carry avalue(what it adds to a total, possibly negative or zero) and an optionallabel(how it reads); repeated faces are kept, so weighting a value means listing it twice. Definitions are passed tocreateDiceEngine({ dice })and named in notation with braces —4d{fate}— matched case-insensitively. Grammar v1.1 also drops the fixed size list: any face count from 2 toMAX_DIE_FACES(1000) parses, sod3andd30need no definition, whiled1andd0are rejected with an error pointing at the modifier the author probably meant. Records move to schemaVersion 2:sidesis any face count,valueis any integer for a custom die (still 1..sides for a plain one), anddieandlabelare new optional fields. Version 1 records still deserialize — they are already valid version 2 data — and are returned stamped as version 2. A custom die is never drawn with a numbered 3D model;PresenterCapabilities.dieSidesaccepts"any"for presenters that can show a die of any face count. - Rationale: ADR-0006 fixed seven die sizes to keep the first grammar unambiguous and every die renderable, and said exotic dice would come as a documented extension rather than silent growth. This is that extension, and it is the smallest one that covers what tabletop systems actually need: Fate/Fudge dice (−1/0/+1), symbol dice, non-transitive and Sicherman sets, and the d3 and d30 that plain arithmetic ranges cannot express. Defining a die by its faces rather than by a size plus a mapping keeps the domain honest — the record can say a die read "+" and contributed 1 — and keeps definitions serializable, so a game system can ship its dice as data to any platform. Braces are unambiguous where a bare
4dFkh1is not:F,Fkh, andFkh1are all plausible names, and resolving that by backtracking through the registry would make the meaning of an expression depend on which dice happen to be registered. Custom dice must not borrow a 3D model, because a model's numerals are painted on: drawing a Sicherman d6 (1,2,2,3,3,4) with a 1–6 model would show a numeral the die does not have, which architecture rule 5 forbids. - Consequences: The schema bump is the first migration this project has performed, and it exercises the policy ADR-0006 promised: a 0.4.0 core reads 0.1–0.3 records, while an older core rejects a version 2 record cleanly as an unsupported version rather than misreading it. Serialization can no longer fully validate a custom die's values — the definition lives outside the record — so it checks structure, magnitude, and arithmetic, and treats face legality as the definition owner's business; a plain numeric die is still checked against 1..sides.
DieOutcome.sidesandRollGroupOutcome.sideswiden from the seven-size union tonumber, which is a breaking type change for consumers who annotated withDieSides; that type remains exported for the sizes with a standard shape. Rolls with custom or unusual dice fall back to 2D tiles for the whole event, so a themed table degrades visibly when a Fate die joins it. Notation grows a second spelling for dice, which future extensions (exploding dice, rerolls) must fit alongside. - Alternatives considered: Bare names such as
4dF(familiar, but ambiguous against keep/drop suffixes — deferred, and addable later without breaking braces); allowing arbitrary sizes only through registration (turnsd3into ceremony for no safety gain); afaceValues: number[]on the group node instead of a named definition (inlines a die's whole face list into every expression and every record, and gives it no identity to share or store); keepingvalueas a face index with a separate contribution field (leaves every consumer to do the lookup, and makes totals impossible to verify from the record alone); staying on schemaVersion 1 by re-meaningvaluein place (would let an old core silently misread a Fate die's −1 as an out-of-range d3 face).
- Date: 2026-07-27
- Status: Accepted (extends ADR-0006 and ADR-0015)
- Decision: Grammar v1.2 adds two modifiers to a dice group.
!explodes: a die reading its highest face adds another die of the same kind, chaining up toMAX_EXPLOSIONS_PER_DIE(10).r<n>rerolls: a die readingnor below is rolled again, repeating up toMAX_REROLLS_PER_DIE(10), whilero<n>rerolls each die at most once. A group's modifiers may be written in any order and always apply in the order reroll, explode, keep/drop; each may appear only once, and canonical notation prints them in that order, so4d6kh3r1normalizes to4d6r1kh3. Extra dice are ordinary entries ingroup.dice, in the order they were rolled, markedsource: "reroll" | "explosion"; a roll a reroll threw away stays in the record withrerolled: trueandkept: false. Both fields are additive and optional, so the event schema stays at version 2. Each die is finished — rerolled and exploded — before the next one starts, and the modifiers add no draws when they are not used, so existing seeds mean exactly what they meant before. The parser rejects a reroll threshold that covers every face or none, and!on a die whose every face is its highest. - Rationale: ADR-0006 named exploding dice and rerolls as the extensions the grammar would eventually need, and they are the two that game systems actually require: Savage Worlds and Feng Shui explode, D&D's great-weapon fighting and countless "reroll 1s" rules do not. Recording extras as ordinary dice in rolled order keeps one shape for consumers — a presenter that could draw four dice can draw six without knowing why there are six — and keeps
subtotalverifiable from the record alone, which the serializer already checks. Keeping the discarded roll rather than silently replacing it makes a roll auditable: "you rerolled a 1 into a 5" is exactly what a player wants to see, and a replay that hid it would be a different roll. Caps exist because both features are unbounded in principle: a die that always explodes would hang the engine, and an engine that hangs on a hostile expression is a denial of service in a browser tab. Thresholds are "at or below" rather than "equal to" because the common instruction is "reroll 1s and 2s", whichr2then says directly;rocovers the once-only variant. - Consequences: A group can now contain more dice than its count, which any consumer reading
group.dice.lengthas "how many were asked for" will get wrong —notationand the term'scountremain the source of that. Keep/drop counts are still validated against the declared count at parse time, so4d6!kh5is rejected even though the pool may reach five; that is conservative and can be relaxed later without breaking anything. A rerolled die iskept: falseand so renders exactly like one dropped by a selection: a presenter that wants to show it struck through rather than dimmed can readrerolled. The!andrspellings are now reserved, which constrains future modifiers; compounding (!!) and penetrating (!p) explosions remain available and unimplemented. - Alternatives considered: Equality thresholds (
r1meaning exactly 1) — simpler to state, but the common rule reroll-1s-and-2s then needs two modifiers; a nestedextrastructure per die (faithful to causality, but every consumer must flatten it, andsubtotalstops being checkable in one pass); replacing a rerolled result in place (loses the audit trail, and makes a replay disagree with what happened); no caps, with a documented warning (an infinite loop is not a documentation problem); fixed modifier order in the source text (rejects4d6kh3r1, which is unambiguous, for no benefit); implementing exploding variants now (!!,!p) — real systems use them, but nothing here needs them yet, and each adds a spelling that is hard to withdraw.
- Date: 2026-07-27
- Status: Accepted (extends ADR-0005 and ADR-0006)
- Decision: A
SessionRecordis an ordered log of resolved events —{ kind: "session", schemaVersion, events }— created withcreateSession(events), serialized withserializeSession/deserializeSession, and played back withreplaySession(session, presenter, { signal, onEvent }). Every event is revalidated on the way in and on the way out, so a session is exactly as trustworthy as a single record, and events from an older schema version are upgraded individually. A replay consumes no randomness: it presents outcomes that were decided when they were rolled, so replaying leaves a seeded engine's stream exactly where it was. It reproduces results, never the show — motion, timing, camera, and even which medium the presenter used are free to differ. The engine records nothing itself: an application keeps the events it already has and hands the list tocreateSession. A session carries no timestamps and no seed. Replay is therefore distinct from re-resolution, which is what ADR-0005 already guarantees: re-running the same seed through the same expressions produces the same records. - Rationale: Four documents promised "replayable" records without saying what that meant, and the two plausible meanings have opposite properties. Re-resolution needs the seed and the expressions, reproduces the records, and consumes randomness; re-presentation needs the records, reproduces nothing but them, and consumes none. Naming both and shipping the one that was missing is cheaper than letting each consumer invent it — and settling it now, rather than under pressure from a physics presenter, is what keeps the answer "the record, never the motion". Keeping the engine free of recording preserves the property that it holds no state beyond its random source, which is what makes one engine per seed a reliable unit; a log is something the application already has. Timestamps and seeds were left out because a session that carried them would imply guarantees about pacing and re-rolling that this decision explicitly refuses.
- Consequences: "Replayable" now has one meaning in the documentation and one API behind it. A replay through a 3D presenter will look different every time, by design; an application that needs the same picture twice must record the picture, not the roll. Sessions are capped at
MAX_SESSION_EVENTS(10,000) so a hostile payload cannot exhaust memory during validation.replaySessionis deliberately thin — barely more than a loop with abort checks — and is the seam where pacing, scrubbing, or partial replay would go if they are ever needed. Because the engine does not record, an application that forgets to keep its events has nothing to replay; that is a documented trade against engine statefulness rather than an oversight. - Alternatives considered: Recording inside the engine (
createDiceEngine({ record: true })) — convenient, but it makes the engine stateful and turns every engine into a memory leak with no eviction policy; storing seed plus expressions instead of records (smaller, but it makes replay depend on the RNG never changing, and ADR-0005 allows a superseding ADR to change it); timestamps in the session (pacing is presentation, and a core with no clock should stay that way); making a session anInteractionEventkind so presenters could "present a session" (a container is not an outcome, and every presenter would have to learn to loop); a nested per-event presentation record capturing what was drawn (that is a recording of the show, which is a different feature and probably a video).
- Date: 2026-07-27
- Status: Accepted, and implemented as
@diceforge-sdk/presenter-physics. - Decision (proposed): A physics presenter simulates a roll headlessly and ahead of time, recording each body's transform per step until it comes to rest, then plays the recorded trajectory back as an ordinary animation. The collider is the idealised sharp solid, and the model is cosmetic — art drawn inside an invisible container that the physics alone sees. Before playback begins, each die's mesh is rotated inside that collider by a rotation from the solid's own symmetry group, chosen so the recorded face occupies the place the simulation's face landed in. The collider is unchanged by a symmetry, so the physics is untouched and nothing is corrected on screen. The remap is derived from geometry — matching face frames between the two faces — and never from
FORGE_FACE_ROTATIONS. It ships as a separate package (@diceforge-sdk/presenter-physics), so the engine dependency stays out of@diceforge-sdk/renderer-web. - Rationale: ADR-0007 forbids an animation that corrects itself, and the outcome is decided before presentation begins, so a simulation cannot be allowed to choose the face. Steering it would be visible; rotating the die after it settles is a snap. Exploiting the die's symmetry is invisible by construction, and it is available: a spike measured every ordered face pair on all six shapes — 4/4 through 20/20, 720 pairs in total — admits a symmetry remap (
packages/renderer-web/src/math/symmetry.test.ts). Recording the trajectory first, rather than simulating live, removes the requirement that the engine be deterministic across runs: the simulation happens once, off-screen, and playback is pure animation. That also keeps presentation frame-rate independent, makes reduced motion a matter of jumping to the last recorded pose, and fits the existing contract wherepresent()awaits an animation it controls. - Colliding the shipped model instead was measured and rejected.
npm run physics -- --hull=glbparses the.glb, welds its vertices and merges coplanar triangles, and the result simulates perfectly well — every shape settles, nothing tunnels. It fails on the two things that matter. The remap breaks completely (180 of 180 resting poses had no symmetry available), because a bevelled d20 is not twenty faces but roughly 620 facets, and no symmetry carries one bevel sliver onto another in a way that repositions a die face. And it costs 17–3295 ms of wall clock per roll against 2–6 ms for the solid — the d10, at 430 facets, is 500 times more expensive. The idealised solid is both the cheaper collider and the only one the technique works on. - The roll happens in a tray, and the camera frames the tray. On an open table a roll scatters further the more dice it has — 124 mm at p95 for five d20s, 230 mm for twenty, against 16 mm dice — so a camera that framed the result would shrink the dice as the roll grew and would have to move for every roll. Walls fix the problem instead: scatter is then capped by the tray whatever the roll does. Correction, from building the presenter: the claim that a camera could then simply frame the tray and never move was wrong. A tray big enough to settle a roll cleanly is far bigger than the dice need, so fitting the walls leaves the dice unreadably small — a d20 at about a twelfth of the frame. The camera frames where the dice came to rest, centred on them, with a floor so a lone die does not fill the screen. It therefore does move between rolls, but the tray bounds how much, which is the benefit that survives. Measured, a radius of about
die × (5 + 0.8√n)settles every trial in roughly a second with 96–100% of dice resting square on a face; the floor of five is what a single die needs, which only showed up once the package was measured at n = 1. The tray is rectangular and shaped to the viewport, which costs nothing in settling — widening only adds room — and gives the dice somewhere to spread that the camera can see. Tighter trays crowd: below about five die-widths, settling stretches past four seconds and one die in ten comes to rest leaning on a wall or a neighbour, where its recorded face is still exact but harder to read. - There is no pre-roll latency, and my earlier note claiming 0.7 s was a misreading of the harness's own columns — that figure is how long the roll takes to watch, which is the point of it. Simulating the whole trajectory costs 4 ms for one die, 14 ms for ten and 44 ms for forty: under a single 60 Hz frame up to about ten dice. A recorded trajectory runs 6 kB to 438 kB over the same range.
- Consequences: Separating the collider from the art buys more than speed. Because the physics decides nothing — the core resolved the outcome before presentation began — a mismatch between collider and model cannot bias a result. The collider supplies symmetry, face count, and resting planes; the model supplies everything visible. A themed die may therefore be bevelled, hollowed, skull-shaped, or missing whole faces for effect, and still roll correctly, so long as its face planes are scaled to sit on the collider's (matching bounding radii instead would rest a bevelled die 3–9% off the table, measured). The presenter needs per-shape symmetry rotations, which the spike derives from the solid geometry the renderer already carries; a themed model would need the same treatment measured from its mesh, or the theme must declare it. Five of the d10's kite faces list their vertices out of cyclic order in the source geometry — harmless today, since nothing triangulates them any more and cannon rebuilds its own normals, but a presenter feeding those rings to a physics engine must normalize them first. A recorded trajectory costs memory proportional to dice × steps (a three-second roll at 60 Hz is ~180 transforms per die — kilobytes). Simulation cost is paid up front as latency before anything moves, which bounds how many dice a roll can show.
PresenterCapabilities.mediagains nothing: this is still"3d", and an application chooses it by picking the package. The die that lands is physically plausible but not physically caused by the recorded outcome, and the ADR-0017 guarantee already says a replay reproduces results and not motion — a physics replay will look different every time, which is now a documented property rather than a surprise. - Engine:
cannon-es(MIT, pure JavaScript, 774 kB unpacked, no WASM). Chosen over@dimforge/rapier3d-compat(Apache-2.0, 8.2 MB, WASM) on weight, and confirmed by measurement rather than assumed.tools/physics/harness.mjsrolls dice headlessly and reports; over 20 trials of 5 dice per shape: every shape settles every time, in 0.71–0.80 s mean and under 1.13 s worst, scattering 123–269 mm at p95 for 16 mm dice, with no die passing through the floor and every resting pose remappable onto every face at 0.0000° error. A whole roll's recorded trajectory is 28–37 kB. The settings are measured, not chosen: cannon's default 10 solver iterations leaves a dodecahedron in a permanent limit cycle — dead flat, then kicked into a 3.3 rad/s spin, every two seconds forever — and 16 is the threshold where every shape settles; looseningsolver.tolerancefrom its default stops every shape settling regardless. Rest must be cannon's own sleep, because a velocity threshold never sees every die quiet at once while the solver is injecting energy. - Alternatives considered: Steering the simulation toward the recorded face (visible correction, and it makes presentation authoritative in fact if not in name); rotating the die once it rests (a snap at exactly the moment the player is looking); simulating twice with a corrected initial orientation (needs run-to-run determinism, and pre-rotating changes the trajectory, so the second run does not land where the first did — the symmetry remap avoids both problems); deriving the remap from the shipped face-rotation tables (measured and rejected: those bake a yaw for numeral legibility, so only 10 of 100 pairs on a d10 and 80 of 400 on a d20 are genuine symmetries, and the rest would rest the die in a pose the solid cannot hold); putting physics inside
renderer-webbehind a flag (every consumer pays for the dependency in install size whether or not they use it, which is the mistake ADR-0013 avoided with art).
- Date: 2026-07-28
- Status: Accepted. Supersedes the collider-construction and camera-framing parts of ADR-0018; the recorded trajectory and the symmetry remap stand unchanged.
- Decision: A die's physics collider is built from the model's own calibrated face table —
faceRotations[value - 1], the rotation that brings numeralvalueto the top — by intersecting the half-spaces those directions define. A face of the collider therefore is a numeral, indexed by value, andPhysicsDieRequestrequires the table. A throw that leaves any die seated below 0.9995 (about 1.8°) is thrown again rather than shown, up to six attempts. The dice area is a fixed rectangle ofdieWidth × 3.5on its shorter side, shaped to the stage, and the camera frames that area and nothing else. - Rationale: ADR-0018 built the collider from
dieGeometry, which orders and orients each solid however its construction happens to produce, and nothing ever related that to where the numerals are. The physics put a geometric face upward and the model showed whichever numeral was printed there. Measured across all six shapes: 57 of 60 faces showed the wrong number — a d6 rolling 1 displayed 2 — and the three that were right were coincidences. The d10 was worse than mislabelled:dieGeometry(10)builds a trapezohedron that is not the shipped d10 at all (their pairwise-angle spectra differ by 0.38), so the simulation was colliding a shape the player never sees, and dice came to rest visibly cocked — up to 41°, which is what "not settled on a number" looked like. Deriving the collider from the calibrated table removes the correspondence entirely rather than adding a mapping to maintain, and it means a third-party theme with its own dice gets a correct collider without shipping geometry. The fixed tray answers the complaint that a camera fitted per roll made the same die a different size on every throw; a tray is a thing on a table, and it does not resize itself. - Why the tests did not catch it: they asked the wrong question, confidently.
index.test.tsverified that the recorded face was strictly highest amongdieGeometry's own normals — self-consistent, true, and blind to the numerals — while the VRT baselines were generated from the buggy build and locked it in. The tests now measure the direction the calibrated table gives for each numeral, which is what the player reads, and assert both that the right numeral is up and that it lies flat. Every face of every shape is checked, not a sample of three. - Consequences:
PhysicsDieRequestgains a requiredfaceRotations, sosimulateRollcannot be called without saying where the numerals are — the previous signature let a caller omit the only thing that made the answer meaningful. The presenter only rolls dice whose theme carries a calibrated table and delegates the rest, which it already did for other reasons. Retrying costs a few milliseconds per rejected throw against 4 ms per die, and it makes the motion depend on the seating outcome, so a seeded throw still reproduces exactly but consumes a variable number of draws. A fixed tray means a large roll packs in more tightly rather than zooming out, which is what a real tray does;trayRadiusoverrides it.faceNormalsnow uses Newell's method rather than the direction of a face's centroid, because a centroid points along the normal only for faces symmetric about it — true of every Platonic solid, false of the d10's kites, and wrong there by about 17°. - Measured after the change: 0 of 60 faces wrong, every shape; every die flat-seated at 1.000 with 0.0° tilt on the shown numeral, against 26–41° before. The tray size is the tightest that holds: swept 2.5–7 die-widths against 1, 5 and 10 dice, everything settles once bad throws are retried, but below 3.5 a crowded tray keeps producing leaners that six attempts cannot clear. At 3.5 a d20 spans a seventh of the frame instead of a fourteenth.
- Alternatives considered: Mapping geometric faces onto printed ones with a permutation and an alignment rotation (tried first; it works for d4/d6/d8/d12/d20, which are the same solid merely rotated, but cannot help the d10, where the two solids genuinely differ — and it leaves a correspondence to get wrong later); fixing
dieGeometry(10)to match the shipped model (narrower, but it leaves the physics depending on two sources agreeing forever, and does nothing for third-party dice); nudging a badly-seated die with an impulse instead of re-throwing (a correction on screen, which ADR-0007 rules out); keeping the per-roll camera fit and accepting the size change (rejected by the product owner: a dice area should be a fixed thing).
- Date: 2026-07-28
- Status: Accepted.
- Decision: Sound for a physics roll is derived from the recording's own collisions. The world run records every contact as
PhysicsImpact— time, body, surface (felt/wall/die), and closing speed in m/s at real scale — onPhysicsRollandPhysicsFlip. A pure function,impactSchedule, turns impacts into knocks (time, gain, bandpass frequency, decay), filtering solver chatter below 0.12 m/s and merging contacts from the same body within 55 ms, both thresholds measured. Knocks are synthesized with Web Audio — one shared noise buffer through a per-knock bandpass and gain envelope — and scheduled on the audio clock beside the animation. It ships inside@diceforge-sdk/presenter-physicsbehindsound?: boolean, default false. Reduced motion skips sound along with the animation it would have accompanied. There is no audio plugin contract. - Rationale: The recorded trajectory already says when each die struck what and how hard; audio played from that data is synchronized by construction, where a sound layer beside the presenter would be guesswork — two presenters given the same event simulate different throws, so only the code holding the recording can sound it. Synthesis over samples is what makes the feature shippable now: there are no audio assets to source, license, or load, and
assets/LICENSES.mdstays empty of third-party sound. Default-off because sound is something an application chooses, not something a library springs; theAudioContextis created lazily inside the first presented roll, which runs in the click's call stack — the moment autoplay policy allows it — and an environment without Web Audio gets a silent player, never an error. - Why no contract: ARCHITECTURE's rule is that plugin contracts are added when a second implementation makes them necessary, not in advance. This is the first audio implementation; a contract drawn from it alone would be its shape with extra steps. When a second audio consumer exists — sampled sound packs, engine-side audio —
PhysicsImpactis the data a contract would carry, and it is already public. - Consequences:
PhysicsRollandPhysicsFlipgainimpacts, additive and recorded from cannon's own collide events, so they cost nothing when unused. The impact stream is deterministic per seed (asserted), and knock variation is derived from the impact data rather than an RNG, so the same recording sounds the same. Loudness maps speed at the simulation's scale, making it independent of the caller's units. The synthesis constants — material voices, thresholds — are tuned by ear against measured speed ranges and are internal: not API, free to improve.PresenterCapabilitiessays nothing about sound; that vocabulary can arrive with the contract, if one is ever justified. - Alternatives considered: Sampled audio assets (an asset-pack licensing and loading problem before the first sound plays; synthesis needs neither, and a sample pack can still arrive later as a theme-like option); a standalone audio presenter implementing
InteractionPresenterwithmedia: ["none"](cannot sync — it never sees the trajectory the visual presenter simulated); an audio plugin contract now (forbidden by the second-implementation rule, and rightly); playing sounds fromplay()'s rAF loop rather than the audio clock (rAF throttles in background tabs and jitters under load; the audio clock does neither).
- Date: 2026-07-28
- Status: Accepted. Refines ARCHITECTURE's adapter definition: the "no alternate rules engines" rule becomes "no divergent rules engines," enforced by data rather than by sharing a runtime.
- Decision: An engine that cannot run TypeScript gets a native port of the headless core, and every port is held to conformance vectors exported from the TypeScript core:
packages/testing/vectors/core-vectors.json, generated bytools/conformance/export-vectors.mjsand shipped with@diceforge-sdk/testing. The vectors freeze the reproducibility contract as data — seeded RNG streams (cyrb128 seeding over UTF-16 code units into xoshiro128**), face sampling, normalized parses, parse-error positions, fully resolved schema v2 records, and coin sequences — and a port must reproduce them bit for bit. A vitest test regenerates the file on every run, so the committed vectors can never drift from the core; changing them is a breaking change under ADR-0005 and requires a superseding ADR plus re-verification of every port. The first port is GDScript, atadapters/godot/— a Godot 4 addon (DiceForge.seeded(seed).roll("2d20kh1+3")) plus a host project whose conformance scene runs all vectors and exits nonzero on any mismatch. Verified in Godot 4.7.1: 48 checks, 0 failures, and a single flipped rotate constant fails 16 of them. Unity follows the same strategy as a C# port against the same file, unwritten. - Rationale: The alternatives to porting are worse in ways that matter to games. Embedding a JavaScript runtime drags megabytes and a GC into every game for dice arithmetic; a sidecar process is absurd for an offline SDK; and compiling the TS core to WASM makes debugging opaque and platform integration ugly, for a core that is ~600 lines of portable logic. What actually needs preserving is not the runtime but the semantics — "the same seed produces the same rolls on every platform" is the README's second sentence — and semantics can be pinned mechanically. The vectors do for cross-language what the golden tests (ADR-0005) already do for cross-release: make drift a test failure instead of a bug report. Error positions are in the vectors because they are API; error messages are not, because wording never was.
- What the port must copy exactly, learned the careful way: JavaScript's
Math.imulis a 32×32→low-32 multiply, and GDScript's 64-bit integers overflow (silently) at(2^32-1)^2, so the port multiplies in 16-bit halves.charCodeAtwalks UTF-16 code units while Godot strings are code points, so the seed hash splits astral characters into surrogate pairs itself rather than trusting any buffer's byte order. Keep/drop ranking needs no stable sort because the core's comparator is total — ties break by roll order — which is precisely why the core was written that way. - Consequences: ARCHITECTURE's claim that adapters "do not create alternate rules engines" is refined, not reversed: a port is an alternate implementation, and the vectors are what forbid it being an alternate engine. The addon depends on the class cache for nothing — internal references are
preloads — so it works pasted into any project without an editor import pass. Records are Dictionaries shaped exactly like schema v2 JSON with optional keys omitted, soJSON.stringify(record)from Godot is readable by every other DiceForge platform, though byte-identical serialization (key order) is deliberately not promised. GDScript has no exceptions, so notation errors return{ "error": { position, message } }— same positions, different delivery. Presentation in Godot (3D dice, themes) is future work with its own decisions to make; this ADR covers the headless core only. The conformance scene is not yet in CI — that needs a Godot binary on the runner, tracked in TASKS. - Alternatives considered: Embedding QuickJS/V8 (size, GC pauses, and a debugging seam through every stack trace); WASM via AssemblyKit/javy (opaque, and Godot's WASM story is extensions, not scripts); C# via Godot .NET for a single shared Unity/Godot port (GDScript reaches every Godot build while .NET builds are a subset — and the vectors make a second port cheap anyway); generating ports from a shared spec (a spec DSL is a third implementation to keep honest); trusting careful code review instead of vectors (the physics presenter shipped showing 57 of 60 wrong faces past careful review).
- Addendum (2026-07-30): The vectors grew additively from 48 to 57 checks — degenerate seeds (an astral emoji pair,
"NaN", 1000 characters) and six hostile parse errors from the core's adversarial probes, including non-ASCII digits, which a port with a locale-aware digit test would wrongly accept. No existing vector changed, so this is coverage, not a contract change. One boundary was measured while doing it: a lone-surrogate seed cannot be a vector, because JSON interchange cannot carry unpaired surrogates (RFC 8259 §8.2 — Godot's parser rejects the whole file, .NET's would too). That behaviour is locked in the TypeScript suite only; the cross-port seed contract covers well-formed Unicode text. Godot 4.7.1 re-verified: 57 checks, 0 failures.
- Date: 2026-07-30
- Status: Accepted
- Decision: The headless core's public contract is declared stable, and every change to it from this date forward must be additive and carried by its own dated ADR. The frozen surface is: notation grammar v1.2 (counts, sizes,
d%, custom diced{id},kh/kl/dh/dl,!,r/ro, signed terms and constant modifiers, with every documented cap and error position); event schema v2 with its v1 read path; the RNG contract (cyrb128 over UTF-16 code units into xoshiro128**, rejection-sampled faces, golden sequences plus the degenerate-seed goldens — already frozen by ADR-0005, now bounded by ADR-0021's interchange addendum); the presenter contract (InteractionPresenter,PresenterCapabilities,presentationSupport— ADR-0008/0014); sessions and replay (ADR-0017); and the conformance vectors as the cross-port enforcement of all of the above. "Additive" means: new syntax must be text that errors today, new record fields must be optional or arrive under a schema version bump with a read path for every prior version, and no existing vector value may change. The next release that publishes this statement is the natural candidate for1.0.0; tagging it is the product owner's call, not this ADR's. - Deferred by decision, not omission — success-counting pools. Pool notation (
7d10>=8: count the dice that pass a target instead of summing faces — World of Darkness, Shadowrun, Year Zero, and roughly half of tabletop) is out of scope for 1.0, blessed as such by the product owner on 2026-07-30. It is the one major dice family whose result is not a sum, so it touches the record's meaning, not just the parser — and that is exactly why it waits: the notation dialect and edge rules (botches cancelling successes, glitch thresholds, whether!composes as "10-again") should be shaped by a real integration asking for a real system, not guessed. It arrives additively when that happens: target syntax is text that errors today, per-die success flags and a group success count ride a schema bump with the proven v(n-1) read path, keep/drop is rejected in pool groups, and the vectors grow with it. Until then, a pool-system app can roll the dice through DiceForge and count outside it — supported, just not yet first-class. - Rationale: Every extension seam this freeze relies on is already load-tested, not aspirational: grammar growth is additive by construction (new syntax errors today, so no parsing expression can change meaning), the v1→v2 schema migration shipped and is regression-tested in both directions, the presenter contract survived three consumers it was not designed around, and the vectors turn "the ports match" from a hope into a bit-for-bit gate that measurably bites. Meanwhile the strongest force for freezing is on the roadmap right now: the Unity C# port duplicates whatever the core is when it starts, and a moving target is the most expensive thing a port can have. The hardening pass (144 tests, 95%/92% coverage, 57 conformance checks, adversarial probes banked as suites) is what makes the declaration honest rather than hopeful.
- Consequences: The Unity port targets a fixed contract from day one. Grammar proposals now begin with "show the expression that errors today"; record proposals begin with "show the version bump and the read path". Error positions remain API; error wording remains free. What this ADR does not freeze: the presentation packages (
renderer-web,presenter-physics, the engine adapters' presenters) keep iterating freely — motion, theming, audio and story playback are where the product visibly grows, and their internals were never contract. Reversing any frozen element — an RNG change, a breaking grammar change, a vector value — requires a superseding ADR and re-verification of every port, per ADR-0005/0021. ROADMAP's "1.0 readiness" line for API and schema stabilization is satisfied by this ADR; the remaining 1.0-readiness items (platform test matrix, security/maintenance policies) are process work, not API work. - Alternatives considered: Shipping pools before the freeze (rejected for now: additive later at no architectural cost, and the dialect deserves a real customer — recorded above so the omission is a decision, not an accident); no formal freeze (the Unity port would chase a moving target, and "stable because nobody changed it lately" is not a contract); freezing the presentation packages too (that is where iteration lives, and their conformance suite already pins the parts that behave like contract); calling the freeze
1.0.0in this ADR (versioning is a release statement — the ADR makes it possible, the product owner makes it).
## ADR-XXXX: Short title
- **Date:** YYYY-MM-DD
- **Status:** Proposed | Accepted | Superseded
- **Decision:**
- **Rationale:**
- **Consequences:**
- **Alternatives considered:**