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 inassets[].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.glb → foo.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.jsonsidecar, whichmodelMetaPathFor()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.tsxfilters 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 undermedia/outright (codemedia-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 oflightandcameraonly (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#
- Manifest reference — the fields (
entry,assets) that determine which paths are required. - Assets — extension allowlist, size caps, content-type policy.
- Sidecars —
*.meta.jsonand*.deck.jsonin full. - Cover art and screenshots — the
media/folder's exact rules. - Anatomy of a mod — a guided walk through a complete tree.
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.
