Dicey Table

Required and conventional files

Exactly one file is required to publish a mod: the manifest, diceytable.mod.json, at the repo root.

Everything else — the setup scene, the script, every model, texture and sound — is manifest-declared, not path-conventional. Nothing about DiceyTable's publish pipeline hard-codes "there must be a scripts/ folder" or "assets must live under assets/." What actually gates a publish is: does the manifest's entry.setup, entry.script, and every assets[] entry point at a file that exists in the tree? (In the editor you never write assets[] yourself — it is derived from the file tree on every save, minus anything you set to load on demand via excludedAssets. The manifest editor's Files tab shows what each file will do.)

How "complete" is actually decided#

Two pure functions in packages/shared/src/modFileTree.ts compute this:

  • treePathsForManifest(manifest, manifestPath = "diceytable.mod.json") — returns the canonical set of repo paths a manifest implies: the manifest file itself, entry.setup (if present), entry.script (if present), and every entry in assets[].
  • missingManifestPaths(manifest, files, manifestPath) — diffs that canonical set against an actual file tree and returns whatever's missing. This must be empty before a publish proceeds. A non-empty result is the complete, precise list of what to fetch (on pull) or push (on publish).

So the real rule is: a mod is complete when every path its own manifest names is present. The manifest is self-describing; there's no second, implicit list of required files to keep in sync with it.

The convention table the editor actually produces#

Nothing below is enforced by the scanner — these are the paths the in-app Edit Mode shell (apps/web/src/ui/TableEditModeShell.tsx) and its sub-editors write to when you use them normally. Deviating from them is legal as long as your manifest's entry/assets point at wherever you actually put things.

Path Produced by Notes
diceytable.mod.json manifest editor The one required file.
setup.json Edit Mode scene save Conventional entry.setup target. Modern format is edit-scene — see Setup JSON for the full schema.
scripts/main.js script editor Conventional entry.script target; must end in .js.
assets/models/… asset upload / model editor Model files (.glb/.gltf(+.bin)); folder is convention only.
assets/models/source/… model import Meshes as imported — no colliders, materials or scripts attached yet. Surfaced as Models → Source in the editor.
assets/textures/… asset upload / texture pipeline Texture files; folder is convention only.
assets/decks/<slug>.deck.json deck editor Custom-deck definition — see Sidecars. Path is generated by deckDefinitionPathFor(), slugified from the deck name.
assets/decks/<slug>/cards/<NNN>-<stem>.webp deck editor One WebP per card face, zero-padded 3-digit index (String(index).padStart(3, "0")). The per-card images are the durable source of truth for the deck — the sheet below is a generated artifact. Never declared in manifest.assets, so players never download them; the table renders the sheet. Still published to the repo and synced back into the editor. Decks created before this moved have their sources at assets/textures/decks/<slug>/cards/…, which is still recognised.
assets/textures/decks/<slug>/sheet-1.webp deck editor The composed sprite sheet built from the per-card images, referenced by the deck definition's face.texturePath.
<model-stem>.meta.json model editor (item defaults) Sidecar next to a model asset — see Sidecars. Path is generated by modelMetaPathFor(), which strips only the model's last extension segment (foo.v2.glbfoo.v2.meta.json).
media/ media pipeline (cover/screenshot capture) Reserved folder — see below.
screenshots/<yyyymmdd-hhmmss>-<id>.png (or .webp) + .json photo mode in Preview Raw captures and their sidecars (camera, size, tier, visibility). Never declared in manifest.assets (isDeclarableModAsset excludes the folder, like thumbnails/) and never thumbnailed, so players never download them. Still published and synced. Not the store page's images: those are media/. See The Screenshots gallery.

Filing models: the source/ bucket#

assets/models/source/ is a conventional bucket, shown as a folder under Models in the editor. Imports land there, including everything converted to GLB on the way in — it means "the mesh as it arrived", before colliders, materials or scripts are attached.

The bucket is by path, so it is a filing choice you make and can see in the repo. It is not inferred, and it does not itself change anything about a model:

  • What a model actually carries is decided by its <stem>.meta.json sidecar, which modelMetaPathFor() places beside the model. The sidecar follows the file into whatever folder it sits in.
  • The bucket is not required. Models directly under assets/models/ keep working, and the Models folder itself still lists everything, so nothing is hidden by adopting it.
  • Built-in preset models have no repo path and belong to no bucket.

There is deliberately no prefabs/ model folder. A prefab is a named, reusable, configured object — see Prefabs — not a folder of meshes. One mesh plus one sidecar can back many prefabs, each with its own name, art and script, which is exactly what a folder of files cannot express.

The reserved media/ folder#

media/ is a special, reserved convention (packages/shared/src/modMedia.ts), not just a suggestion:

  • Hidden from the editor's file explorer. TableEditModeShell.tsx filters project assets with !isMediaPath(asset.path) — you won't see it while browsing your own files, because it's meant to be produced by the media pipeline, not hand-edited.
  • Image-only at scan time. The scanner's validateMediaImageAsset() rejects any non-image file placed under media/ outright (code media-unsupported-type) — see Cover art and screenshots for the exact sizes, crop rules and error messages.
  • Fixed target sizes and encoding: cover 512×512, cover-thumb 256×256, screenshots 1280×720, encoded WebP at quality 0.7.

The file-tree envelope#

Every published mod's tree is represented, metadata-only (never the bytes themselves — those live in OPFS/GitHub), by modFileTreeSchema:

{
  schemaVersion: 1,          // MOD_FILE_TREE_SCHEMA_VERSION, literal
  files: ModFileEntry[]      // max 2000 entries
}

Each ModFileEntry is:

Field Constraint
path Safe relative repo path (no .., no leading /, no backslashes, no http(s)://).
contentType 1–160 chars.
sizeBytes integer, 0 to 50 * 1024 * 1024 (50 MB).
sha256 required — 64 hex chars.
kind one of manifest | setup | script | asset | text | json | other, defaults "asset".
source one of workspace | upload | generated, defaults "upload".

Two hard caps worth remembering: 2000 files per tree, 50 MB per file.

diffTrees() and incremental publish#

diffTrees(base, next) compares two file-entry arrays by path + content hash and returns { added, changed, removed }. This is what makes publishing incremental rather than a full re-upload every time: base is what GitHub currently has, next is your working tree, and the diff tells the publish flow exactly which blobs to push (added/changed) and which to leave alone (unchanged paths whose sha256 matches are reused as-is). The same diff also drives pull-cache invalidation on the play side — a changed sha256 for a path means peers must re-fetch it.

Annotated example: a real repo tree#

This is mods/example's actual tree, annotated (verified 2026-07-27):

diceytable.mod.json     ← required. Declares entry.setup, entry.script, assets: ["setup.json"]
gametable.mod.json      ← optional legacy fallback, kept byte-identical to diceytable.mod.json
setup.json              ← entry.setup target — the objects present at load
scripts/
  main.js                ← entry.script target
README.md                ← generated on publish from the manifest; the scanner never reads it

And a simpler mod with no script:

diceytable.mod.json     ← required. entry: { setup: "setup.json" } — no script key
setup.json               ← entry.setup target
assets/
  board.png               ← declared in assets[]

A hypothetical mod that also ships a custom deck and a model would add:

diceytable.mod.json
setup.json
scripts/main.js
assets/
  models/
    meeple.glb
    meeple.meta.json       ← sidecar: default spawn props + physics overrides for meeple.glb
  decks/
    tarot.deck.json         ← custom-deck definition
  textures/
    decks/
      tarot/
        cards/
          000-fool.webp
          001-magician.webp
          …
        sheet-1.webp         ← generated composite of the per-card images above
media/                      ← reserved; hidden from the editor's file explorer
  cover.webp
  cover-thumb.webp
  screenshot-1.webp
screenshots/                ← photo-mode captures from Preview; published, never declared
  20260914-153012-k3f9a1c2b7xq.png
  20260914-153012-k3f9a1c2b7xq.json

Required vs. conventional — the table that can't be misread#

Required to publish Conventional (editor-produced, not enforced)
diceytable.mod.json at repo root ✅ always
Everything named in entry.setup / entry.script / assets[] ✅ if declared, must exist at that exact path The path itself (setup.json, scripts/main.js, assets/…) is convention only
<model>.meta.json, <deck>.deck.json only if you reference them from assets[] Path pattern (<stem>.meta.json, assets/decks/<slug>.deck.json) is convention
media/cover.webp, media/screenshot-N.webp only if coverImage/screenshots[] reference them Sizes/encoding are fixed by the media pipeline; the folder name media/ is a hard reserved convention (scanner-enforced image-only rule), the file names are not
screenshots/… never Written by photo mode in Preview. The editor never declares it, so it cannot enter a player's download; the folder name is what keeps it out
gametable.mod.json never Legacy fallback only — recommended to keep byte-identical to diceytable.mod.json if you keep it at all
README.md never Generated by the editor on every publish from your manifest's name, type, category and description, and overwritten each time — edit it in Mod Details, not here. The scanner never looks at it.
Anything else not referenced by the manifest never Purely author convenience; the scanner never looks at it

Object-level fields these files can express#

When a setup scene (or a template) declares objects, a few TableObjectState fields are worth knowing about because file-tree readers will encounter them:

  • parentId — nullable string, ≤96 chars. Which entity (if any) this object is parented to.
  • components[] — union of light and camera only (max 8 per object, MAX_OBJECT_COMPONENTS). No other engine component is author-addable through this array.
  • displayName — optional, ≤80 chars, free-form human name.
  • physics.rigidbodyEnabled / physics.collisionEnabled — booleans; independent enable flags for the rigidbody and collision engine components.
  • physics.weldChildren — boolean; when set, a parent and its children behave as one compound dynamic body with combined mass rather than independent jointed bodies.

The three names, and why they're different: id is the only address — nothing else resolves an entity. label is the slug: the object's uniqueness key, and for kind: "card" objects specifically, the identity that drives hidden-information redaction (see the hidden-information memory). displayName is the optional human-readable name shown in the UI; when absent, the UI falls back to showing label.

See also#

modFileTreeSchema#

Exported from @diceytable/shared as modFileTreeSchema. 8 fields across 2 tables.

Field Type Required Default Min / Max Pattern Rule Description
schemaVersion 1 yes
files modFileEntrySchema[] yes <= 2000 items

schemaVersion#

A literal 1, and the gate that makes a tree self-describing: a document without it doesn't parse as a file tree at all, so a bare { files: [...] } is rejected rather than accepted as a version-less tree.

There has only ever been one version. If a second arrives, this is the field a reader branches on before trusting any other key in the document.

files#

The whole repo tree, described as metadata only. No bytes travel in this structure — they live in GitHub and in the browser's local mirror — which is what lets a tree be compared, stored and sent around cheaply.

That is what the helpers alongside the schema are for: treePathsForManifest derives the paths a manifest implies, missingManifestPaths reports which of them the tree is missing (a publish shouldn't proceed while that list is non-empty), and diffTrees splits two trees into added, changed and removed.

modFileTreeSchema.files#

Field Type Required Default Min / Max Pattern Rule Description
path string yes 1–180 chars Use forward-slash relative paths without traversal or external URLs.
contentType string yes 1–160 chars
sizeBytes integer yes >= 0, <= 52428800
sha256 string yes ^[a-f0-9]{64}$
kind "manifest" | "setup" | "script" | "asset" | "text" | "json" | "other" no "asset"
source "workspace" | "upload" | "generated" no "upload"

files.path

The repo-relative location of the file, and the identity of the entry: diffTrees pairs entries between two trees by path alone, so a rename reads as one removal plus one addition rather than as a move.

isSafeRelativeModPath is what the schema's rule enforces — forward slashes only, no leading /, no backslashes, no empty or .. segments, and no http:// or https:// URL. A path that escapes the repo can't be written into a tree, so nothing downstream has to re-check it.

files.contentType

The MIME type recorded for the file when the entry was created. It is stored, not sniffed — the schema checks only that it is a non-empty string, so nothing verifies that a .glb was labeled model/gltf-binary.

Write the type you actually served the bytes as. A reader deciding how to interpret a file has this string and the path, and a wrong value here is more misleading than a generic application/octet-stream.

files.sizeBytes

The byte length of the file, recorded so a tree can be budgeted and displayed without fetching anything. The per-entry cap applies to the number you declare, so a tree cannot describe a file larger than that at all — an oversized asset is a parse failure of the tree, not a publish failure later on.

See Limits and caps for how this sits alongside the other size limits a mod runs into.

files.sha256

The content hash, and the field that makes a comparison incremental instead of wholesale. diffTrees calls a path present in both trees changed only when the two hashes differ, so an unchanged file is skipped rather than re-uploaded or re-fetched.

Regenerate it whenever the bytes change. A stale hash makes a modified file look identical and it gets skipped; a hash that changes for unchanged bytes costs you the whole point of the comparison. This value is the tree's own key — it is not the git blob sha GitHub's tree API reports, which is a different hash of a different input.

files.kind

What role the file plays in the mod — the manifest, the setup document, the mod script, an asset, or plain text/JSON that is none of those. It lets a reader find the three files that matter without re-deriving them from names and extensions.

other is the honest answer for anything that is along for the ride: a README, a license, a source file you keep in the repo but never load. See Required and conventional files for which paths a mod is actually expected to have.

files.source

Where the bytes came from: workspace for a file that is part of the project itself, upload for one you brought in from your machine, generated for one a tool produced.

It is provenance, not policy — every value publishes the same way and nothing treats a generated file differently from an uploaded one. Its use is answering "why is this here?" months later, and spotting a generated artifact you meant to rebuild rather than commit.