Sidecars
A sidecar is a small JSON file that sits beside (or names) another asset and carries configuration the asset file itself can't express. DiceyTable has two: model meta sidecars (*.meta.json) and custom-deck definitions (*.deck.json). Both are ordinary declared assets — list them in assets[] like anything else — but their content is additionally validated against a shared schema at scan time, on top of the generic asset checks in Assets. Model assemblies (*.assembly.json, below) are not sidecars, but get the same content validation.
Why sidecars get extra validation#
Both sidecar shapes are consumed by every client — spawn paths and card-face rendering read them directly — so a malformed sidecar would otherwise fail identically and repeatedly on every peer at runtime, with no clear diagnostic. The scanner's validateSidecarJsonAsset() catches that at publish time instead, with a path-bound error pointing at the exact file.
- Invalid model meta:
`Model meta sidecar does not match the expected schema (${field}: ${message}).`(codeinvalid-model-meta) - Invalid deck definition:
`Custom deck definition does not match the expected schema (${field}: ${message}).`(codeinvalid-deck-definition) - Either, if not parseable JSON:
`${label} is not valid JSON: ${parse error}.` - Invalid model assembly (including unparseable JSON):
`Model assembly does not match the expected schema (${message}).`(codeinvalid-model-assembly)
Model assemblies — <name>.assembly.json#
An assembly is one model made of several part GLBs, each with its own transform. The editor writes one when you split a model: splitting models/castle.glb produces models/castle/castle.assembly.json plus one models/castle/castle-<part>.glb per part. Use the assembly's path anywhere a model path goes — a custom model object or a room decor placement — and every peer loads it as a single model.
{
"version": 1,
"source": "models/castle.glb",
"parts": [
{
"id": "tower",
"label": "Tower",
"model": "models/castle/castle-tower.glb",
"position": { "x": 2.1, "y": 0, "z": -4 },
"rotation": { "x": 0, "y": 0, "z": 0 },
"scale": { "x": 1, "y": 1, "z": 1 }
}
]
}
positionplaces the part's base centre (the centre of its X/Z bounds, at its lowest point), so rotation and scale pivot about the bottom middle of the part.rotationis in degrees.rotationandscaledefault to identity. Scale must be positive.modelis a repo-relative.glb/.gltfpath from the repo root, not from the assembly's own folder. A part cannot be another assembly.partsholds 1–256 entries, and eachidmust be unique.- Materials: each part binds its materials from its own meta sidecar (
models/castle/castle-tower.meta.json), so rebinding a part changes it in every assembly that uses it. An assembly sidecar (castle.assembly.meta.json) is only a fallback for slots a part's sidecar doesn't bind. A table object's own per-slot assignments still win over both.
This is purely additive: it only rejects sidecar-shaped files that are internally broken. No existing content-type, size, or extension rule is loosened or skipped.
Model meta sidecars — <model-stem>.meta.json#
Path convention: modelMetaPathFor(modelPath) strips only the model file's last extension segment and appends .meta.json, in the same directory:
| Model path | Sidecar path |
|---|---|
assets/models/foo.glb |
assets/models/foo.meta.json |
assets/models/foo.v2.glb |
assets/models/foo.v2.meta.json |
assets/models/foo (no extension) |
assets/models/foo.meta.json |
Recognized by: isModelMetaPath(path) — case-insensitive suffix match on .meta.json with a non-empty stem.
What it's for: default spawn properties for a custom model, applied by applyModelAssetMetaToDefinition() when the model is spawned — so every table an author drops the model onto gets consistent defaults without re-configuring by hand each time.
Shape (modelAssetMetaSchema, packages/shared/src/modelAssetMeta.ts) — every field is optional (.partial()); unknown keys are stripped:
| Field | Type / constraint |
|---|---|
kind |
table-object-kind enum (card, deck, die, token, board, bag, custom, card-holder, button) — what the model spawns as. Absent means custom |
material |
sound-material enum (wood, cardboard, metal, plastic, card, tile, generic, silent) — the piece's physical surface. Absent resolves from kind |
color |
hex color, ^#[0-9a-f]{6}$ |
rotation, scale |
{ "x": …, "y": …, "z": … } objects (vector3TupleSchema — an object, despite the name; a JSON array is rejected). rotation is in degrees, scale is a multiplier on the model's native size |
spawnHeight |
number, finite, ≥0 — note: read directly by the caller when computing spawn position; not auto-applied by applyModelAssetMetaToDefinition() |
faceDown, locked |
boolean |
stackCount |
integer, 1–1000 |
containerMode |
container-mode enum |
capacityLimit |
integer, 1–1000 |
tags |
string array, ≤100 entries, each ^[a-z0-9_-]+$, ≤32 chars |
metadata |
free-form record — merged per-key over the definition's own metadata (sidecar keys win) |
materialSlots |
record of source material name → project material id — the model's default per-slot material binding, merged per-key over the definition's own materialSlots (sidecar keys win) |
bodyType |
physics body-type enum |
mass |
number, positive, finite |
friction, restitution, linearDamping, angularDamping |
number, 0–1 |
collisionShape |
collision-shape enum |
collider |
the authored collider list — { "mode": "auto" }, or { "mode": "custom", "entries": [ … ] } with 1–8 entries, each carrying its own offset/rotation/scale |
triggers |
up to 8 trigger volumes, each { id, name, shape, position, rotation, size, tag? } |
kind and material classify the piece; the rest of the file dresses it. kind decides which
gameplay actions and sound events a spawned object has at all, and material decides what it sounds
and weighs like — neither is a clip id or a render material. A model that is a die is a die every
time it is placed, which is why both belong on the asset rather than on each entity in setup.json.
The Model editor writes them from Spawn defaults ▸ Type / Surface, and changing Surface there
re-derives the mass/friction/restitution/damping numbers below (tune them afterwards and your
values are kept). An entity can still be reclassified on the table, and an explicit kind in
setup.json wins over the sidecar's.
The bodyType/mass/friction/restitution/linearDamping/angularDamping/collisionShape fields are flat in the sidecar (not nested under a physics key) but get collected into the spawned definition's physics override at apply time.
materialSlots is what makes an imported model's materials editable. Importing a GLB lifts its
materials into project materials and its images into textures/ files, then removes those images
from the GLB — so the bytes are stored once rather than twice. The model keeps its material
names, which are the slot keys, and this record says which project material each one became. It is
written once, at import, and every spawn of the model inherits it through
applyModelAssetMetaToDefinition().
The consequence worth stating plainly: for a model imported this way, materialSlots is not
decoration. Remove it and the model renders untextured, because the textures it used to carry
internally now live beside it and only the binding connects the two. Editing the bound material is
what changes how the model looks — which is the point.
It applies to room decor as well as to table entities. A roomPack.decor placement names a
modelPath and never builds an entity definition, so it reads this record from the same per-asset
registry that carries collider, triggers and the shadow flags — which is also why those keys
work for scenery.
Units, once, for the whole file: every length is in feet measured at the entity's identity scale (the world unit in DiceyTable is a foot — a playing card is about 0.29 ft wide), and every rotation is Euler XYZ in degrees. The entity's own scale is applied on top at spawn, so author at unit scale.
collider and triggers are both optional and additive — a sidecar written before they existed is still valid, and both keys are documented field by field below. Two conventions are worth knowing up front:
.collider.glb, the sibling bake. AconvexHullormeshentry with"source": "baked"reads its geometry from a GLB next to the model: strip the model file's last extension and append.collider.glb, soassets/models/keep.glbpairs withassets/models/keep.collider.glb.bakedPathoverrides that when the file lives elsewhere. Declare the collider GLB inassets[]like any other asset.triggers[].tagcannot be a platform tag. It goes through the same author-tag validation as every other tag — 1–32 characters matching^[a-z0-9_-]+$— and adt:-prefixed value is rejected, so a mod cannot mint a reserved platform tag from a sidecar.
Worked example:
{
"kind": "token",
"material": "metal",
"color": "#c9a227",
"scale": { "x": 1, "y": 1, "z": 1 },
"locked": false,
"tags": ["premium-piece"],
"mass": 0.4,
"friction": 0.6,
"collisionShape": "convexHull",
"collider": {
"mode": "custom",
"entries": [
{
"id": "body",
"name": "Body",
"shape": "cylinder",
"radius": 0.06,
"height": 0.02,
"axis": 1
}
]
},
"triggers": [
{
"id": "slot",
"name": "Coin Slot",
"shape": "box",
"position": { "x": 0, "y": 0.05, "z": 0 },
"rotation": { "x": 0, "y": 0, "z": 0 },
"size": { "x": 0.2, "y": 0.1, "z": 0.2 },
"tag": "coin-slot"
}
]
}
Path: assets/models/coin.meta.json, sitting beside assets/models/coin.glb.
Custom deck definitions — <slug>.deck.json#
Path convention: assets/decks/<slug>.deck.json, where <slug> comes from deckDefinitionPathFor(name) — lowercased, NFKD-normalized, non-alphanumerics collapsed to -, trimmed, capped at 60 chars.
Recognized by: isDeckDefinitionPath(path) — case-insensitive suffix match on .deck.json with a non-empty stem.
Companion asset paths (editor convention, not schema-enforced): per-card face images at assets/decks/<slug>/cards/<NNN>-<stem>.webp (zero-padded 3-digit index) and a generated composite sheet at assets/textures/decks/<slug>/sheet-1.webp. The per-card images are the durable source of truth; the sheet is a build artifact regenerated from them. Only the sheet is declared in manifest.assets — card sources are authoring input and are never downloaded by players. (Decks created before the sources moved under assets/decks/ still have them at assets/textures/decks/<slug>/cards/…; both paths are recognised.)
Shape (customDeckDefinitionSchema, packages/shared/src/customDeck.ts), schema version 1:
| Field | Type / constraint |
|---|---|
schemaVersion |
literal 1 |
id |
string, 1–120 |
name |
string, 1–120 |
face |
a sheet: { texturePath, columns (1–64), rows (1–64), cardCount (≥1), cardWidthPx (1–8192), cardHeightPx (1–8192) } — cardCount must fit columns × rows |
uniqueBacks |
boolean — TTS "Unique Backs": each card windows its own cell of the back sheet |
back |
discriminated union: { kind: "sheet", sheet: <same shape as face> } or { kind: "single", texturePath } |
sideways |
boolean, default false — TTS "Sideways" (cards render/rotate landscape) |
backIsHidden |
boolean, default false — TTS "Back is Hidden": hand-hidden cards show the back instead of the deck's own hidden-face convention |
cardFields |
array, ≤64, default [] — the data model every card inherits; each { key, label?, type, defaultValue?, options? } |
deckFields |
array, ≤64, default [] — same shape, describing the deck itself |
data |
object, ≤64 keys, default {} — the deck's own field values |
cards |
array, 1–1000, each { cardId, label?, faceIndex (≥0), backIndex? (≥0), count (1–1000, default 1), data? } |
Four cross-field checks run beyond the shape above: every cards[].faceIndex must be < face.cardCount; (when uniqueBacks is true and back.kind === "sheet") every cards[].backIndex must be < back.sheet.cardCount; every cardId must be unique; and the counts must add up to no more than 1000. Field keys must also be unique within cardFields and within deckFields. Each produces a field-pathed schema error, which the sidecar scan surfaces via the invalid-deck-definition message above.
Copies (cards[].count): a card's copies share one sheet cell, so face.cardCount counts distinct cards while the spawned deck's stackCount is the sum of the counts. Copy 1 keeps the plain cardId; copies 2..N get a #2, #3, … suffix (expandDeckCardIds() / baseDeckCardId()). A definition written before count existed is unchanged in meaning: every entry defaults to one copy.
Data model: cardFields/deckFields declare typed fields (text, number, boolean, select) with an inherited defaultValue; a card overrides one by putting the same key in its own data. Bags are sparse — an absent key inherits — and hold flat scalars only. Read them through resolveDeckCardData() / resolveDeckData(), which apply inheritance and coerce each value to its declared type. A value that disagrees with its type is coerced at read time, never rejected at validation time, so retyping a field cannot make an authored deck unloadable.
Hand-hidden face convention: unless backIsHidden is set, the hand-hidden face shown for a card is the last image on the face sheet (face.cardCount - 1), matching Tabletop Simulator's convention — reserve your final sheet cell for a generic card-back-style image if you want that look.
Worked example:
{
"schemaVersion": 1,
"id": "tarot-major-arcana",
"name": "Tarot — Major Arcana",
"face": {
"texturePath": "assets/textures/decks/tarot/sheet-1.webp",
"columns": 5,
"rows": 5,
"cardCount": 22,
"cardWidthPx": 512,
"cardHeightPx": 880
},
"uniqueBacks": false,
"back": { "kind": "single", "texturePath": "assets/textures/decks/tarot/back.webp" },
"sideways": false,
"backIsHidden": false,
"cardFields": [
{ "key": "arcanum", "label": "Arcanum", "type": "number", "defaultValue": 0 },
{ "key": "element", "type": "select", "options": ["fire", "water", "air", "earth"] }
],
"deckFields": [{ "key": "ruleset", "type": "text", "defaultValue": "rider-waite" }],
"data": {},
"cards": [
{ "cardId": "the-fool", "label": "The Fool", "faceIndex": 0, "count": 1, "data": { "element": "air" } },
{ "cardId": "the-magician", "label": "The Magician", "faceIndex": 1, "count": 1, "data": { "arcanum": 1 } }
]
}
the-fool has no arcanum entry, so it inherits the field's 0; the-magician has no element, so it resolves to null. Neither card stores what it agrees with the model about — that is what keeps a large deck's file (and its replicated slice) small.
Files the editor manages for you#
Three more kinds of file live in a mod repo beside your assets. You never write them by hand: the editor writes, moves and removes them. They are listed here so you know what they are when you see them on GitHub.
Library link records — <file>.link.json and links/materials/#
A link record records where a library copy came from: which library item a project file (or material) was copied from, and what that item looked like at the time. The source is one of:
- a platform preset —
{ "type": "preset", "presetKind": "model" | "material" | "texture" | "deck", "presetId": … }; - a plugin resource at a pinned commit —
{ "type": "plugin", "pluginId": …, "commitSha": <full 40-hex sha>, "resourcePath": … }.
Where it lives:
| Copy of | Record path |
|---|---|
| A file | Next to it, keyed by the full filename: textures/wood-floor.webp → textures/wood-floor.webp.link.json |
| A material (materials live in the scene, not in files) | links/materials/<material id>.link.json, with the id percent-encoded into one filename segment — material ids can contain : and / |
The full filename is used, not the stem as .meta.json does, because foo.glb and foo.png
must not share a record.
Shape (assetLinkRecordSchema, packages/shared/src/assetLinks.ts), strict — unknown keys
are refused:
| Field | Meaning |
|---|---|
version |
literal 1 |
source |
the library item, as above |
sourceHash |
a hash of the source when the copy was last synced with it |
base |
the source's editable properties at that time — stored, never re-fetched, because a preset has no history and an old plugin commit may be unreachable later |
overrides |
{ set?, unset? } — the project's changes from base |
linkedAt, syncedAt |
ISO timestamps |
detached |
{ at, reason }, present once the copy's bytes were changed locally |
What happens to it:
- It moves with its file. Moving or renaming the file in the
Asset Explorer moves its
.link.jsontoo. - Changing the file's bytes detaches it. Optimizing an image, splitting a model or uploading
over the file asks first, then sets
detached. The record stays so the credit can still say where the work came from, as an unlockedderivedFromcredit.
The matching credit on the manifest is a locked credit.
The editor writes a record whenever it makes a linked copy: Add to project, pasting or
dropping library items into the Project pane, or Duplicate to project. See
Credits and linked copies. A
project that still has a material using a built-in's id, from before linked copies existed,
gets a record under links/materials/ written for it when the project opens.
Baked thumbnails — thumbnails/#
The editor renders a 256×256 WebP preview of your models, assemblies, materials, prefabs, decks and textures, and keeps them in the repo so they don't have to be rendered again on every open. How they are generated and shown is on The Asset Explorer § Thumbnails.
| Subject | Thumbnail path |
|---|---|
| A file | thumbnails/<repo path>.webp — models/keep.glb → thumbnails/models/keep.glb.webp |
| A material | thumbnails/@materials/<id>.webp |
| A prefab | thumbnails/@prefabs/<id>.webp |
| The index | thumbnails/index.json |
The @ keeps a material or prefab thumbnail from colliding with a real materials/ folder; a
file path whose first folder starts with @ gets an extra @ for the same reason. Material
and prefab ids are escaped into a single filename segment.
thumbnails/index.json is { "version": 1, "rendererVersion", "entries": { <key>: { "inputHash", "file", "width", "height" } } }.
inputHash covers everything that changes the picture — the item and what it depends on — so
the editor can tell which images are stale.
Three rules worth knowing:
- Published, but never declared. Thumbnails are pushed to GitHub with the rest of the repo,
but they are not added to
manifest.assets:isDeclarableModAssetexcludes everything underthumbnails/. Players never download them to play, and they don't count against the declared-asset cap. - Never a sync conflict — for generated files. If the local and GitHub copies of
thumbnails/index.json, or of a thumbnail at exactly the path the editor would write (thumbnails/<key>.webp), differ, the local copy is kept and the image is regenerated. You are never asked to choose. Any other file you put underthumbnails/yourself — saythumbnails/cover.png— is yours, and a conflict on it prompts like any other file. - Hidden in the explorer. Generated thumbnails appear on their subjects, not as files, and
they move or are deleted along with the file they picture. A file you put under
thumbnails/by hand is not generated, so the explorer shows it — but because nothing underthumbnails/is declared, don't keep assets a table needs in that folder.
Scripts, sounds, JSON and other text files have no thumbnail. Neither do card data files (they show an icon) or deck card source images, since the deck's own thumbnail covers them.
Folder keepers — .keep#
A zero-byte .keep file makes an empty folder real. Git has no empty folders, and project
folders are otherwise derived from file paths, so a folder made with New ▾ ▸ Folder would
vanish on reload without one.
- It is hidden in the explorer.
- It is never declared in
manifest.assets:isDeclarableModAssetrequires an allowed file extension, and.keephas none. - Once the folder holds anything else, the keeper is no longer needed and the editor removes it.
Replication note#
Neither sidecar type travels over the wire as its own object. A deck's resolved contents are copied into TableObjectDefinition.metadata.customDeck at spawn time (see buildCustomDeckObjectMetadata()); a model's meta sidecar is applied once, at spawn, to build the definition. Peers never fetch .meta.json/.deck.json files directly at runtime for a TableIntent — they're an authoring-time convenience, not part of the replicated protocol.
collider and triggers follow the same rule and go one step further: neither becomes replicated per-entity state at all. Every peer that loads the model resolves them itself from this file, so no snapshot field, no snapshot schema version and no migration changed to add them. Trigger volumes are local physics artefacts rebuilt per peer, and only the host's copies are ever sampled — which is why both trigger events are host-only.
See also#
- Assets — the generic content-type/size checks these files receive on top of the schema validation above.
- Manifest reference §
assets— a sidecar must be inassets[]to be fetched and validated. The editor declares it for you; a hand-built repo has to list it. - COLLISION — the shape options
colliderauthors, and the three conditions the runtime refuses a mesh-derived collider under. onTriggerEnter(table script) andonTriggerEnter(mod) — the only two things atriggersentry ever does.
customDeckDefinitionSchema#
Exported from @diceytable/shared as customDeckDefinitionSchema. 42 fields across 6 tables.
| Field | Type | Required | Default | Min / Max | Pattern | Rule | Description |
|---|---|---|---|---|---|---|---|
schemaVersion |
1 |
yes | — | — | — | — | — |
id |
string |
yes | — | 1–120 chars | — | — | — |
name |
string |
yes | — | 1–120 chars | — | — | — |
face |
customDeckSheetSchema |
yes | — | — | — | 1 further cross-field rule (message built at validation time). | — |
extraFaceSheets |
customDeckSheetSchema[] <br>same shape as face |
no | — | <= 7 items | — | — | — |
uniqueBacks |
boolean |
yes | — | — | — | — | — |
back |
customDeckBackSchema |
yes | — | — | — | — | — |
sideways |
boolean |
no | false |
— | — | — | — |
cardRotationRule |
customDeckRotationRuleSchema |
no | — | — | — | — | — |
art |
customDeckArtSchema |
no | — | — | — | — | — |
backIsHidden |
boolean |
no | true |
— | — | — | — |
hiddenFaceIndex |
integer |
no | — | >= 0, <= 9007199254740991 | — | — | — |
cardFields |
customDeckFieldSchema[] |
no | [] |
<= 64 items | — | — | — |
deckFields |
customDeckFieldSchema[] <br>same shape as cardFields |
no | [] |
<= 64 items | — | — | — |
data |
customDeckDataSchema |
no | {} |
— | — | 1 further cross-field rule (message built at validation time). | — |
cards |
customDeckCardSchema[] |
yes | — | 1–1000 items | — | — | — |
Whole-object rules:
- 1 further cross-field rule (message built at validation time).
schemaVersion#
The version gate on the document shape, and the reason a .deck.json from a newer editor
fails loudly instead of half-loading. There is no migration function for deck definitions
the way there is for session snapshots, so a document that doesn't match this exact literal
is rejected whole — by the publish-time sidecar scan, and by the editor's own load path,
which turns it into "This deck has no valid definition yet" rather than a scrambled table.
That strictness is the point: a partially understood deck would put the wrong art on the wrong card on every peer at once, which is far harder to notice than a file that refuses to load. The deck editor stamps this for you; you write it only when hand-authoring.
id#
The deck's own identifier, copied into a spawned deck's metadata.customDeck.definitionId
so you can tell which definition a table Entity came from. Nothing resolves it: the sheet
grid and the whole card mapping travel with the Entity, so the runtime never looks the
definition back up by this value. Treat it as provenance, not as a handle.
The deck editor mints a UUID-backed value once and never rewrites it, so it survives renaming
the deck. Uniqueness is checked nowhere — two definitions can share an id and both work,
which costs you only the ability to tell their spawned Entities apart afterward.
name#
The deck's human-readable name. It becomes the spawned deck Entity's label, truncated to
80 characters, so it is what you read on the table and in the hierarchy, not only a caption
inside the editor.
Renaming a deck moves nothing on disk. The definition's slug, and with it the per-card image
folder under assets/decks/, are fixed when the deck is created and read back from
the file's own path afterward, so a renamed deck keeps its original folder name. That is
deliberate: re-slugging on rename would orphan every card image the deck owns.
face#
The sprite sheet every card's front is cut out of: one texture plus the grid that divides it.
This is the deck's FIRST face sheet, and on most decks its only one. A deck whose cards do not
fit a single 4096-pixel image continues onto extraFaceSheets — 62 poker-sized cards at
512x758 already need a second sheet — with each card's faceIndex staying one global cell
index across the whole run.
The grid fields are the contract between whatever composed the sheet and the runtime that samples it. Get one of them wrong and nothing errors; every card renders the wrong rectangle. If you are building a sheet by hand, match the row-major-plus-V-flip convention described under sheet generation.
extraFaceSheets#
Face sheets two onwards, for a deck whose cards do not fit one image. face is always sheet
one; these follow it in order. Absent on a single-sheet deck, which is most of them.
A card's faceIndex is one global cell index across the whole run, not a
{sheet, cell} pair. Sheet one contributes its cardCount cells, then sheet two continues
the numbering, and so on. So on a deck whose first sheet holds 40 cards, faceIndex: 40 is
the first cell of the second sheet. That is what lets every definition written before
multi-sheet decks existed keep working untouched: with no extraFaceSheets, the global index
and the cell index are the same number.
Look a cell up with resolveDeckFaceCell() rather than indexing face yourself. Indexing
face directly with a global index is silent on a multi-sheet deck — it reads out of range,
or worse, in range on the wrong sheet, and the card simply shows the wrong art.
At most eight sheets. The real limit is memory, not the format: a 4096-pixel sheet decodes to 64 MB of video memory, so the Asset pack budget starts warning at 96 MB and refuses at 256 MB across all of a pack's sheets. Lowering the card resolution is usually the better answer to a deck that needs many sheets — halving the cell size quarters the memory and quadruples the cards per sheet.
uniqueBacks#
Whether each card windows its own cell of the back sheet. This flag means something only when
back.kind is "sheet": with a single back image the runtime returns that whole texture
before it ever consults this field, so on a single-back deck both uniqueBacks and every
cards[].backIndex are dead weight.
With a back sheet and this set, a card's back is its backIndex, falling back to its
faceIndex — Tabletop Simulator's parallel-sheets convention, where face cell n and back
cell n belong together — and then to cell 0. With a back sheet and this clear, every card
gets cell 0, which becomes the deck's one shared back. The deck editor writes only single
backs today, so a back sheet is hand-authored.
back#
Where the reverse of a card comes from: { "kind": "single", "texturePath": … } for one
shared image, or { "kind": "sheet", "sheet": … } for a grid with the same shape as face.
A single back is drawn as the entire texture with no windowing, so its pixel dimensions are
free — it does not have to match the face sheet's cell size or its aspect ratio. A back sheet
is sliced by exactly the same grid math as the face, and which cell a given card gets out of
it is decided by uniqueBacks. The deck editor writes only the single form, defaulting to a
generic back bundled with the client.
sideways#
Tabletop Simulator's "Sideways" flag, recording that the deck's cards are landscape rather
than portrait. It is persisted, and it travels to every peer inside metadata.customDeck —
but no runtime code path reads it. No card is rotated, resized or re-cropped because of it.
Set it to record your intent; do not set it expecting the table to change.
To actually ship a landscape deck, give the cells landscape pixels (cardWidthPx greater than
cardHeightPx) and scale the placed Entity to match.
See Known limitations.
cardRotationRule#
Derive each card's rotation from one of its card fields, so a set is tagged once instead of being turned card by card.
A real TCG mixes orientations inside one deck. If a Star Wars TCG import gives every card a
type field, one rule — "type = battle → 90°" — turns every battle card in a 60-card deck.
Without it you would set rotationQuarter on each of them by hand, and
redo the work every time you re-import.
The rule is an authoring convenience and nothing more. It is resolved when the deck is
spawned, so the replicated metadata.customDeck slice carries one resolved number per card and
never the rule itself: the runtime does no rule evaluation per draw, and a mod script reading a
drawn card sees a plain rotationQuarter. The practical consequence is that editing a rule
reaches the table the way any other deck edit does — by respawning the deck, not by mutating
decks already in play.
Precedence is fixed: a card's own rotationQuarter wins, then this rule's
match, then its fallback, then
upright. resolveCardRotationQuarter() is the only correct implementation of that order — call
it rather than reading the fields yourself.
Omit the whole block for a deck whose cards are all one way up. Nothing is defaulted in, so a deck authored before rotation existed is byte-identical after a save.
art#
How a card's source image is framed on the card, and what shape the card itself is. Optional:
a deck without an art block behaves exactly as decks did before the block existed — cover,
no bleed, square corners, white background — so nothing already published changes appearance.
Everything here except cornerRadius is baked into the generated face sheet's PIXELS, which
is why changing any of it marks the sheet dirty in the deck editor and needs a Generate &
Save rather than a plain Save. Because they apply at composition time rather than at import,
they stay editable for the life of the deck; the per-card images keep their original aspect
ratio precisely so this remains true.
cornerRadius is the exception: it is copied onto metadata.customDeck and read by the
runtime, which generates the card body's mesh from it. See
Card art.
backIsHidden#
Accepted for backwards compatibility and no longer read. A hidden card shows its back whatever this is set to.
It used to select between "the last cell of the face sheet" (its default) and "the card's back". That default only makes sense for decks authored the Tabletop Simulator way, with a generic image reserved in the final cell; every other deck has a real card there, so every card held in every opponent's hand rendered as that one card's art.
Use hiddenFaceIndex to reserve a cell
deliberately.
hiddenFaceIndex#
A face-sheet cell reserved as the stand-in another player sees in place of a card you are holding. Leave it out — the default — and they see the card's back, which is what a physical card does.
Set it only if your sheet actually contains a generic "?" image, the way Tabletop Simulator decks conventionally reserve their final cell. Naming a cell that holds a real card shows that card's art on every hidden card at the table.
This is a choice about substitute art, not about secrecy. What keeps the card secret is
host-side redaction: the host strips the cardId out of the snapshot before sending it to a
peer who isn't entitled to that card's identity, whatever this field says.
Replaces the old
backIsHiddenbehaviour. That flag used to select between "the last face-sheet cell" (its default) and "the back", so any deck whose last cell was a real card — every deck not authored for Tabletop Simulator — showed that card's face for every hidden card in the game.backIsHiddenis still accepted so existing definitions load, but is no longer read.
cardFields#
The deck's card data model: the fields every card in the deck has, with the value each card inherits until it says otherwise. Declaring a model here is what makes per-card data navigable — the deck editor renders one labelled control per field on every card tile, and a script reading a card knows which keys to expect instead of guessing at a free-form bag.
A field's defaultValue lives here, once; a card overrides it by putting the same key in its
own data. That split is the point: a 100-card deck with a cost field that is 0 on 90 cards
stores one default and ten overrides.
The list travels with every spawned deck and every card drawn out of it, on
metadata.customDeck.cardFields, so a table or mod script can resolve a card's values without
loading the .deck.json. Keys must be unique across the list — a duplicate is a validation
error rather than a silent last-one-wins.
deckFields is the same shape for the deck itself; see that field for the distinction.
deckFields#
The deck's own field model — facts about the deck as a whole rather than about any one
card: a ruleset name, a starting hand size, an expansion label. Same shape as cardFields,
and the same uniqueness rule, but the values live in the deck's single data bag instead of
being inherited by anything.
Use cardFields when the value differs card to card (even if most cards agree); use
deckFields when there is exactly one answer for the whole deck. Putting a deck-wide constant
in cardFields works, but you pay for it in a default that every card carries the meaning of.
Like cardFields, this list is copied onto every spawned deck — and onto every card drawn out
of it, so a lone card still knows which deck's rules it came from.
data#
The deck's own field values — the counterpart to deckFields, exactly as a card's data is
the counterpart to cardFields. Sparse in the same way: an absent key inherits that field's
defaultValue. resolveDeckData(def) merges the two and coerces each value to its declared
type.
Undeclared keys are preserved rather than dropped, so a mod may stash its own values here; they are passed through without coercion because no field declares their type.
Values are flat scalars only (string, number, boolean, null) and the bag holds at most 64
keys. It is replicated on every spawned deck, so keep it to facts the table actually needs —
a rules document belongs in the mod's files, not in here.
cards#
The deck's distinct card list, and its starting order: entry 0 becomes the top of the
spawned deck. Position here does not pick a card's art — faceIndex does — so you can
reorder the deck without touching a single index. The deck editor writes the two in step,
which makes a saved file look more constrained than the schema is.
This array is not the deck's size. Each entry carries a count of physical copies, and the
spawned Entity's stackCount is the sum of those counts, not this array's length. Both are
capped at 1000: the array by its own max, the total by a whole-object rule, so a definition
that validates always spawns at its full size rather than being silently trimmed.
Card identities must be unique — see cardId.
customDeckDefinitionSchema.face#
| Field | Type | Required | Default | Min / Max | Pattern | Rule | Description |
|---|---|---|---|---|---|---|---|
texturePath |
string |
yes | — | 1–400 chars | — | — | — |
columns |
integer |
yes | — | >= 1, <= 64 | — | — | — |
rows |
integer |
yes | — | >= 1, <= 64 | — | — | — |
cardCount |
integer |
yes | — | >= 1, <= 9007199254740991 | — | — | — |
cardWidthPx |
integer |
yes | — | >= 1, <= 8192 | — | — | — |
cardHeightPx |
integer |
yes | — | >= 1, <= 8192 | — | — | — |
Whole-object rules:
- 1 further cross-field rule (message built at validation time).
face.texturePath
The repo-relative path of the face sheet, resolved exactly like a custom model's texture: a
peer prefers a copy already primed in its local cache, and otherwise pulls the bytes straight
from raw.githubusercontent.com for your repo at the published ref. No sheet image is ever
stored on the platform's servers.
Declare it in assets[] like any other file — see Assets. A path
that resolves to nothing does not break the deck: the cards fall back to the standard card
materials, so the failure looks like a deck that lost its art rather than an error anyone
gets told about.
face.columns
How many cells wide the grid is. With rows, it is the only thing that decides where a card's
rectangle lands: cell n sits at column n % columns, row floor(n / columns), counting from
the top-left of the composed image.
Get it off by one and nothing fails validation — every card past the first row is cropped from the wrong place, and the deck renders as a scramble of card halves. Read this off the sheet you actually composed, never off the card count.
face.rows
How many cells tall the grid is. It sets each cell's height in UV space and, with it, the
vertical flip: sheet images are composed with row 0 at the top, while texture space puts
v = 0 at the bottom, so the runtime maps row r to 1 - (r + 1) / rows.
You never do that arithmetic yourself — the shared cardSheetUvWindow helper owns it, and the
deck editor's compositor is written against the same convention. It matters the moment you
build a sheet by hand: pack it top-left first, or the cards come out mirrored top to bottom
against what the image shows.
face.cardCount
How many of the grid's cells actually hold a card. It does not have to equal columns × rows —
trailing cells on the last row are allowed to sit empty — and only the "too many" direction is
rejected: a
count larger than the grid holds fails validation with the grid size quoted back at you. A
count smaller than the grid is normal and costs nothing.
It carries a second job that is easy to miss. The hand-hidden stand-in face defaults to cell
cardCount - 1, so rounding this number up to something tidy points that stand-in at a blank
cell, and other players see an empty rectangle where a card back belongs. Every
cards[].faceIndex is validated against it too.
face.cardWidthPx
The width of one cell in the source texture, in pixels. It is not the card's width on the
table — a placed card is sized by its Entity's scale, in feet — and the runtime does not use it
to compute a card's crop rectangle either. That comes from columns and rows alone.
What it is actually for: with columns, it reconstructs the sheet's full pixel width so the
one-pixel anti-bleed gutter can be converted into a UV inset. Report the real cell width and
the gutter is one real pixel; report an invented one and the inset is the wrong size, which
shows up as a hairline of the neighboring card along a card's edge.
face.cardHeightPx
The height of one cell in the source texture, in pixels — the vertical half of the job
cardWidthPx does, feeding the sheet's full pixel height so the anti-bleed gutter converts to
the right UV inset. It does not set how tall a card is on the table; the placed Entity's scale
does, in feet.
The two together fix your cards' aspect ratio, and the deck editor re-encodes every new upload to them, so changing them is how you reshape a deck between poker, bridge and square. Cards already uploaded keep the pixels they were encoded at and stretch to fill the new cell, which is why a mid-deck change looks like it did nothing until you re-upload.
customDeckDefinitionSchema.cardRotationRule#
| Field | Type | Required | Default | Min / Max | Pattern | Rule | Description |
|---|---|---|---|---|---|---|---|
fieldKey |
string |
yes | — | — | ^[A-Za-z_][A-Za-z0-9_]{0,39}$ |
— | — |
match |
Record<string, cardRotationQuarterSchema> |
yes | — | — | — | — | — |
fallback |
cardRotationQuarterSchema |
no | — | — | — | — | — |
cardRotationRule.fieldKey
Which card field selects the rotation — the key, not the label.
The value looked up for a card is its own override in data, falling back to the field's
declared defaultValue. That fallback is what makes a rule cheap to apply: declare
type with defaultValue: "unit", and only the cards that are not units need a data entry
at all.
The key does not have to be declared in cardFields — data bags tolerate
undeclared keys, so a rule can key off something a mod script writes. It is a much better idea
to declare it: the deck editor can only offer a rotation rule for fields it knows about, and an
undeclared key gives you no UI and no validation.
A key that no card carries and no field declares is not an error. Every card simply misses,
and the rule's fallback applies.
cardRotationRule.match
Field value → quarter turns.
Values are compared as strings, so "battle", 2 and true all work as keys and a
numeric or boolean field needs no special handling: a card whose type is the number 2
matches the key "2". This is deliberate — the alternative is a per-type comparison table that
silently fails to match the moment a field's type changes under it.
null never matches. It is "no value", not the string "null", so a card whose field is
explicitly null falls through to fallback exactly like a card
that has no value at all. An absent field behaves the same way.
Only list the values you actually want turned. Anything unlisted falls to fallback (and, with
no fallback, to upright), so a deck of mostly-portrait cards needs one entry, not one per card
type.
cardRotationRule.fallback
The rotation for a card whose value matches nothing in match —
including a card with no value for the rule's field at all, and one whose value is null.
Omit it for the ordinary case: an unmatched card is then upright, which is what you want when
match lists the exceptions. Set it when the exceptions run the other way — a deck of
landscape cards with a handful of portrait ones is expressed as fallback: 1 plus a short
match for the upright few, rather than as a match entry per landscape card.
A per-card rotationQuarter still overrides this, so a fallback can
never trap an individual card at the wrong orientation.
customDeckDefinitionSchema.art#
| Field | Type | Required | Default | Min / Max | Pattern | Rule | Description |
|---|---|---|---|---|---|---|---|
fit |
"cover" | "contain" |
no | "cover" |
— | — | — | — |
bleedPx |
integer |
no | 0 |
>= 0, <= 256 | — | — | — |
cornerRadius |
number |
no | 0 |
>= 0, <= 0.25 | — | — | — |
backgroundColor |
string |
no | "#ffffff" |
— | ^#[0-9a-fA-F]{6}$ |
— | — |
art.fit
Whether a source image fills the card or fits inside it.
cover scales the image until it covers the whole cell and crops whatever overflows — the
right choice for full-bleed card art, and the default. contain scales until the entire
image is visible and fills the remainder with backgroundColor, which is what you want for
art whose edges matter (a bordered illustration, a scanned card, a logo).
Both modes preserve the source's aspect ratio. Neither ever stretches it non-uniformly.
art.bleedPx
How far the art is pushed PAST the card's edge, measured in card-cell pixels. Print bleed, minus the physical trimming: raise it when a card's art has no margin and you would rather lose a sliver of the edge than risk a hairline of background showing at the corners.
Not an exact per-edge guarantee on a non-square card. Preserving the source aspect means
one uniform scale factor, so growing the art enough to clear the short axis by bleedPx
clears the long axis by proportionally more. cover guarantees at least bleedPx on every
edge and exactly that much on the tightest one. An exact bleed on all four sides would require
scaling the axes differently, i.e. distorting the author's art.
Unrelated to CUSTOM_DECK_SHEET_GUTTER_PX, the fixed one-pixel gutter the sheet composer
draws to stop texture filtering sampling a neighbouring cell. That one guards rendering; this
one is a framing decision.
art.cornerRadius
The card's corner rounding, as a fraction of its SHORT edge — 0 is a square corner and
0.25 is the maximum. Expressing it against the short edge rather than in pixels is what
makes a poker card and a square card look equally rounded at the same value.
This reshapes the card, not just the artwork. The runtime generates the card body's mesh
from this number, so it changes the physical silhouette of the deck and of every card drawn
out of it. That is why it is copied onto metadata.customDeck and travels to every peer: the
sheet's rounded artwork and the generated body have to agree, or a rounded corner would show
a sliver of the wrong thing.
The deck editor previews it live against a real card image.
art.backgroundColor
The #rrggbb fill behind the card art: the letterbox in contain, and the area outside the
rounded corners at any non-zero cornerRadius. Defaults to white.
The corner fill is normally invisible, because the generated card body is rounded by the same
cornerRadius and simply has no geometry there. It matters when you deliberately round the
art more than the body, and it is what you see in the deck editor's preview — so set it to
your card's border colour rather than leaving it white on a dark deck.
customDeckDefinitionSchema.cardFields#
| Field | Type | Required | Default | Min / Max | Pattern | Rule | Description |
|---|---|---|---|---|---|---|---|
key |
string |
yes | — | — | ^[A-Za-z_][A-Za-z0-9_]{0,39}$ |
— | — |
label |
string |
no | — | 1–60 chars | — | — | — |
type |
"text" | "number" | "boolean" | "select" |
yes | — | — | — | — | — |
defaultValue |
customDeckDataValueSchema |
no | — | — | — | — | — |
options |
string[] |
no | — | <= 1024 items; each 1–120 chars | — | — | — |
Whole-object rules:
- 1 further cross-field rule (message built at validation time).
cardFields.key
The name the value is stored under, in both a card's data and the deck's data, and the
name a script reads it by. Constrained to a script-safe identifier — a letter or _ first,
then letters, digits and _, up to 40 characters — so data.cost always works and no consumer
has to quote or escape anything.
Keys must be unique within their list. Renaming one in the deck editor migrates every stored
value across the whole deck; renaming one by hand in the .deck.json does not, and orphans
every value still filed under the old name (they survive as undeclared keys rather than being
deleted, so the fix is to rename them too).
cardFields.label
A human display name for the field, shown above its control in the deck editor. Purely
cosmetic — nothing joins on it, and omitting it falls back to showing the key.
Set it when the key has to stay terse for scripts but the editor deserves better: key: "atk"
with label: "Attack".
cardFields.type
What kind of value the field holds, which picks the deck editor's control and the coercion applied when a value is read:
| Type | Editor control | Coercion |
|---|---|---|
text |
single-line text box | anything stringifies |
number |
number box | numeric strings parse; anything else reads as null |
boolean |
checkbox | "true" and 1 read as true |
select |
dropdown over options |
a value not in options reads as null |
Coercion happens at read time (resolveDeckCardData / resolveDeckData), not at
validation time — the schema does not reject a value that disagrees with its field's type.
That is deliberate: changing a field's type must not make an authored deck unloadable. The
deck editor converts stored values in place when you change a type, so what you see there is
already the coerced result.
select additionally requires a non-empty options list; a select without one is a
validation error.
cardFields.defaultValue
The value inherited by everything that has not overridden this field — every card in the deck
for a cardFields entry, the deck itself for a deckFields entry. Storing it once here, and
only the exceptions per card, is what keeps a large deck's .deck.json (and its replicated
metadata.customDeck slice) small.
Omitting it is not the same as setting it to null in the file, but it resolves the same way:
a field with no default reads as null until something overrides it.
A default that disagrees with the field's type is coerced when read, not rejected — see
type.
cardFields.options
The allowed values for a select field, in the order the deck editor's dropdown lists them.
Required and non-empty when type is select — a select with no choices is a validation
error, not an empty dropdown.
Ignored for every other type. The deck editor drops the list outright when you change a field
away from select, so it does not linger as dead weight in the file.
A stored value that is not in this list reads as null (see type), which is what happens
after you remove a choice that cards were already using — those cards fall back to the field's
defaultValue, or to null if there isn't one.
customDeckDefinitionSchema.cards#
| Field | Type | Required | Default | Min / Max | Pattern | Rule | Description |
|---|---|---|---|---|---|---|---|
cardId |
string |
yes | — | 1–120 chars | — | — | — |
label |
string |
no | — | <= 120 chars | — | — | — |
faceIndex |
integer |
yes | — | >= 0, <= 9007199254740991 | — | — | — |
backIndex |
integer |
no | — | >= 0, <= 9007199254740991 | — | — | — |
count |
integer |
no | 1 |
>= 1, <= 1000 | — | — | — |
data |
customDeckDataSchema |
no | — | — | — | 1 further cross-field rule (message built at validation time). | — |
assetPath |
string |
no | — | 1–400 chars | — | — | — |
rotationQuarter |
cardRotationQuarterSchema |
no | — | — | — | — | — |
cards.cardId
A card's identity, and the value everything else joins on. The spawned deck's replicated per-card entries carry it, the runtime looks up a card's sheet cell by it, and the host's hidden-information redaction strips exactly this string out of a snapshot before it reaches a peer who isn't entitled to see that card — so it is also the one thing a cheating client would want and cannot get.
It must be unique within the deck, and that is validated: a duplicated cardId is a
rejection, because copies of a card are expressed with count rather than by repeating an
entry. A cardId the mapping doesn't contain falls back to the standard card materials.
The ids that reach the table are instance ids, not these: with count copies, copy 1 keeps
this string and copies 2..N get a #2, #3, … suffix. Lookup strips a trailing #<digits>
to get back here, so a cardId of your own that ends that way ("set#2") is indistinguishable
from a copy — don't write one.
cards.label
An optional human-readable name for one card — The Fool, Fireball. It is authoring
metadata: the deck editor shows it in the card grid and restores it when you reopen the deck,
and no runtime code path reads it. It does not name the table Entity, and it is not a slug.
Do not reach for it as an identifier; cardId is what the runtime and redaction key on.
And do not put anything secret in it: the whole card list is copied into the spawned Entity's
metadata.customDeck, which every peer needs in order to render, and redaction neutralizes
card identities there, not labels.
cards.faceIndex
Which cell of the face sheet this card's front is cut from, counted row-major from the
top-left. It is validated against face.cardCount, with the offending index and the sheet size
quoted in the message, so an out-of-range front is caught at publish time rather than at the
table.
It is independent of the card's position in cards: two cards can point at the same cell, and
the deck's order can change without any index moving. The deck editor happens to write index
and position in step, which is worth knowing before you hand-edit a saved deck and assume the
pairing is enforced.
cards.backIndex
Which cell of the back sheet this card's reverse is cut from. It is consulted only when
uniqueBacks is set and back.kind is "sheet" — and it is validated only under those same
two conditions, so an out-of-range value on a single-back deck passes the scan and then never
matters, because nothing reads it.
Omit it on a unique-backs deck and the card falls back to its own faceIndex, which is
Tabletop Simulator's parallel-sheets layout; if that is out of range for the back sheet, cell 0.
See uniqueBacks for what the flag changes about this field and about back.
cards.count
How many physical copies of this card the deck spawns with. This is the whole reason a
100-card deck of 14 distinct designs does not need 100 images: copies share one sheet cell,
one faceIndex, one label and one data row, so the face sheet is sized by the number of
distinct cards (face.cardCount) and never by the deck's actual size.
Every copy still needs its own identity, because the deck's replicated per-card entries key
everything off cardId. Copy 1 keeps the plain cardId; copies 2..N get a #2, #3, …
suffix — expandDeckCardIds() builds that list and baseDeckCardId() maps a suffixed id back
to this entry, which is how a copy finds its shared cell. A deck authored before count
existed is byte-identical under the new rules: every entry defaults to count: 1 and every
instance id stays exactly the cardId you wrote.
The copies of one card are emitted together, in definition order, so a freshly spawned deck is grouped rather than interleaved. Shuffle it if you want otherwise.
The 1000 ceiling here is per entry; a whole-object rule also rejects a definition whose counts
add up past 1000, so a deck that validates always spawns at its full size. A card's copies
are indistinguishable in play — they carry identical data — so use count for genuine
duplicates and separate entries for cards that differ in any way.
cards.data
This card's overrides of the deck's cardFields defaults. Sparse on purpose: a key that
is absent means "inherit", not "empty", so a deck whose cards mostly agree with the model
stores almost nothing per card. resolveDeckCardData(cardFields, card) is the function that
merges the two and coerces each value to its declared type; read the result rather than this
bag directly, or you will see holes where a default should be.
Keys the deck's cardFields do not declare are kept verbatim rather than dropped, so a
mod can stash its own values here and they survive a round-trip through the deck editor. They
are also passed through uncoerced — nothing validates them against a type, because there is no
type to validate against.
Values are flat scalars only (string, number, boolean, null). There is no nesting: this bag
is copied onto every spawned deck and onto every single card drawn out of it, and it rides in
snapshots to every peer, so it is deliberately cheap to serialize. All copies of a card share
this one row — see count.
cards.assetPath
The individual image this card's cell was built from, repo-relative — the link from a packed deck back to the source art it was assembled out of.
Nothing reads it at render time: the runtime windows the packed sheet, which is the whole point of packing. It matters for everything you do to a deck after building it — re-gridding it, adding a card, exporting the originals — all of which need the sources still present.
The deck editor writes this and always has. Declaring it here is what makes it survive being parsed: an undeclared key is stripped, so every tool that inspected a loaded definition saw cards with no sources and concluded the source images were orphans. The project explorer offered to delete the very files the deck needs.
Optional, because a deck assembled from a sheet you supplied directly has no per-card sources to point at.
cards.rotationQuarter
Quarter turns applied to this card when it is drawn, clockwise as the card is seen
face-up: 0 upright, 1 = 90°, 2 = 180°, 3 = 270°.
Rotation is a render-time property and is never baked into the face sheet. Every cell of the sheet is still composed at the same orientation, which is what keeps the packing trivial, keeps a re-grid cheap, and lets you turn a card without regenerating any art. What changes is how the card is drawn: the runtime turns the card's UV window onto its own cell, and an odd quarter also swaps the card body's width and height, because a quarter-turned portrait card is a landscape card. Rotating the art without swapping the body would squash it.
This field is what sideways only ever recorded — see
sideways, which is persisted but read by nothing. Use
rotationQuarter when you want the table to actually change, and use it per card: a real TCG
mixes orientations inside one deck (Star Wars TCG battle and location cards are printed
landscape while units are portrait).
Leave it absent to let the deck's cardRotationRule decide. That is
why it is optional rather than defaulting to 0: an absent value means "follow the rule",
while an explicit 0 pins this one card upright against the rule. Resolve the two with
resolveCardRotationQuarter() rather than reading this field directly — nothing else applies
the precedence correctly.
Only the card's face turns. Its back is drawn upright, because backs are symmetric art and
a turned back on a face-down landscape card just looks broken. The reserved
hiddenFaceIndex cell is drawn upright too: it is one shared image, and
turning it with the card underneath would leak that card's orientation to opponents — for a
card type that is always landscape, that is a real tell.
Copies of a card share its rotation, the same way they share a sheet cell and a data row.
modelAssetMetaSchema#
Exported from @diceytable/shared as modelAssetMetaSchema. 49 fields across 6 tables.
| Field | Type | Required | Default | Min / Max | Pattern | Rule | Description |
|---|---|---|---|---|---|---|---|
kind |
"card" | "deck" | "die" | "token" | "board" | "bag" | "custom" | "card-holder" | "button" |
no | — | — | — | — | — |
material |
soundMaterialSchema |
no | — | — | — | — | — |
color |
string |
no | — | — | ^#[0-9a-f]{6}$ |
— | — |
rotation |
vector3TupleSchema |
no | — | — | — | — | — |
scale |
vector3TupleSchema <br>same shape as rotation |
no | — | — | — | — | — |
spawnHeight |
number |
no | — | >= 0 | — | — | — |
faceDown |
boolean |
no | — | — | — | — | — |
locked |
boolean |
no | — | — | — | — | — |
stackCount |
integer |
no | — | >= 1, <= 1000 | — | — | — |
containerMode |
"random" | "stack" | "queue" |
no | — | — | — | — | — |
capacityLimit |
integer |
no | — | >= 1, <= 1000 | — | — | — |
tags |
string[] |
no | — | <= 100 items; each 1–32 chars | ^[a-z0-9_-]+$ |
— | — |
metadata |
Record<string, unknown> |
no | — | — | — | — | — |
materialSlots |
materialSlotsSchema |
no | — | — | — | materialSlots may not hold more than 64 entries. | — |
castShadows |
boolean |
no | — | — | — | — | — |
receiveShadows |
boolean |
no | — | — | — | — | — |
lightmapStatic |
boolean |
no | — | — | — | — | — |
originAuthored |
boolean |
no | — | — | — | — | — |
bodyType |
"static" | "dynamic" | "kinematic" |
no | — | — | — | — | — |
mass |
number |
no | — | > 0 | — | — | — |
friction |
number |
no | — | >= 0, <= 1 | — | — | — |
restitution |
number |
no | — | >= 0, <= 1 | — | — | — |
linearDamping |
number |
no | — | >= 0, <= 1 | — | — | — |
angularDamping |
number |
no | — | >= 0, <= 1 | — | — | — |
collisionShape |
"auto" | "box" | "sphere" | "capsule" | "cylinder" | "convexHull" | "mesh" |
no | — | — | — | — | — |
collider |
modelColliderSchema |
no | — | — | — | — | — |
triggers |
modelTriggerVolumeSchema[] |
no | — | <= 8 items | — | — | — |
containerInterior |
containerInteriorSchema |
no | — | — | — | — | — |
kind#
What kind of piece this model spawns as — a die, a token, a board, a card-holder, and so on.
Optional: omit the key and the model spawns as custom, which is what every model without a sidecar
does and what every sidecar written before this key existed still means.
The kind is what decides which gameplay actions and sound events an object has at all: a model
classified as die can be rolled and reports a face value, one classified as board is static and
has no pickup sound. Because that is a property of the model itself — a model that is a die is a
die every time it is placed — it belongs here rather than on each entity in setup.json. The Model
editor's Spawn defaults ▸ Type dropdown writes it.
An individual entity can still be reclassified after it is on the table (Edit Mode's PIECE ▸
Type), and a kind given explicitly in setup.json wins over this one. Unlike
collider, this is not per-asset-only config: it is applied
into the spawn definition, so it reaches replicated state and every peer sees the same
classification without reading the sidecar.
Pairs with material, which the kind supplies a default for.
material#
The piece's physical surface — wood, cardboard, metal, plastic, card, tile,
generic or silent — applied to every entity spawned from the model.
Optional: omit the key and the surface is resolved from kind, so
an unset value means "whatever this kind normally is", never silence.
Surface is a semantic descriptor, not a clip id. It selects which impact sounds a piece makes when
it is placed, dropped or slid, and it supplies the physics baseline — mass scales with the surface's
density and the model's scale, friction and bounce come from the surface itself. Changing Spawn
defaults ▸ Surface in the Model editor re-derives mass, friction, restitution,
linearDamping and angularDamping in this same sidecar; you can then tune any of those numbers by
hand and they are kept.
Like kind, this is applied into the spawn definition rather than kept as per-asset-only config, and
an entity can override it on the table (Edit Mode's PIECE ▸ Surface).
color#
For an imported model this tints the procedural stand-in box the entity shows while the GLB is still
loading — and keeps showing if the file never resolves. Once the model attaches, the runtime disables that
box's render component and the GLB's own materials take over, so the color you set here is invisible on a
healthy load and is the only thing a player sees on a broken one.
Pick something deliberately unlike the finished piece. A magenta stand-in reads as "this model did not load"; a wood brown one reads as a wooden piece and hides the failure until someone tries to grab it. To change how the model itself looks, edit its materials in the Material editor rather than reaching for a color here.
rotation#
Euler angles in degrees, applied to the entity at spawn. The runtime rests an imported model with its
X/Z center on the object origin and its lowest point at y = 0, so these angles pivot the model around the
point it stands on: a yaw spins it in place, while pitch and roll swing its base through the table surface.
Use it to correct a model that was authored facing the wrong way, not to pose one. A pose that leaves the
model leaning does not survive first contact with gravity unless the entity is locked or its bodyType
is static — see the three axis fields below.
scale#
A multiplier on the model's authored size, not a size in feet. The runtime measures an imported model and
rests it on the object origin but leaves its scale at 1 — it deliberately does not normalize it into a unit
box the way a card is normalized — so 2 means "twice as big as whatever came out of your modeling tool".
That makes this the field to reach for when a model arrives at the wrong size, and it makes an extreme value a signal to fix the export instead: world units are feet, so a model authored in millimeters arrives hundreds of feet tall. The collider is fitted to the scaled bounds, so this changes what the piece collides with as well as how big it looks.
spawnHeight#
Feet above the table surface, and the one sidecar field the overlay deliberately leaves alone:
applyModelAssetMetaToDefinition skips it because a spawn position is scene-specific, so Edit Mode reads
it separately when it works out where the drop lands.
It behaves as a floor rather than an exact height — the drop point's own height wins when that is higher — and Edit Mode falls back to half a foot when the field is absent. The Model editor's Spawn height control stops at 10 ft, so a hand-edited sidecar can set a value no one can produce through the UI and drop the model in from off-screen. Raise it for something meant to fall into place; keep it near zero for a board or a tile you want laid flat, because anything dropped from height drifts before it settles.
faceDown#
Applies to: card and deck. Those are the only kinds with a face — the setup-template schema refuses
faceDown on any other kind, and the hidden-information redactor requires kind to be card or deck
and this flag to be set before it hides an identity from a peer.
A model sidecar spawns a custom entity, so a faceDown set
here is a replicated boolean that no code path reads. The field is in the shape because the sidecar mirrors
the model preset's default-property block, and a preset can be a card. Leave it out, and turn a card over
with the card's own flip action.
locked#
Locked does two things a static bodyType does not: the body is created static and the entity refuses
grabs, drags and pointer transforms. If what you mean is "this never moves and nobody picks it up", this is
the field — bodyType on its own only settles how the body simulates.
It is a starting state, not a permission. The lock/unlock object action flips it at the table and the new value replicates like any other state, so a player can always release a piece you shipped locked. Set it for boards, terrain and table furniture that a stray die should never shove; leave it off for anything a player is meant to pick up.
stackCount#
Applies to: deck. A deck's visible height is its card count multiplied by a card's thickness, and the
draw and deal paths stop running once the count reaches zero — on a deck this one number is both the model
and the gate.
A model sidecar spawns a custom entity, where neither of
those paths exists: the value rides along in the definition and replicates, and nothing on the table
changes. It is in the shape because the sidecar mirrors the model preset's default-property block, and a
preset can be a deck. Build a real deck in the Deck editor instead.
containerMode#
Applies to: deck and bag. It names the order the host takes items out — stack from the front of the
pile, queue from the back, random from anywhere — and the draw and deal paths refuse to run on any
other kind.
It is a real default, carried onto every object spawned from this model and obeyed by the draw:
resolveContainerConfig (packages/shared/src/tableContainers.ts) reads the first-class field first, the
legacy metadata.containerMode key second, and the per-kind default (random for a bag, stack
otherwise) last. Set it alongside kind — a sidecar that does not
declare a kind spawns a custom Entity, which has no draw path,
so a draw order on its own governs nothing.
capacityLimit#
Applies to: deck and bag. It is the ceiling on how many items a container is allowed to hold, and the runtime
enforces it in exactly one place: combining two stacks is abandoned when the result would exceed the target
container's capacity, leaving both piles untouched rather than trimming the overflow.
That check reads this field. containerCapacityFor (apps/web/src/playcanvas/TabletopRuntime.ts) resolves
it through resolveContainerConfig — first-class field, then the legacy metadata.containerCapacity key,
then unlimited — so the number you write in the sidecar is the number the merge is refused against. It only
bites on a container: a sidecar that does not declare kind spawns a
custom Entity, which never reaches the merge path at all.
tags#
Author tags, and lowercase only here: the sidecar's pattern carries no case-insensitive flag, so Terrain
fails sidecar validation at scan time even though the same text typed into the Inspector is lowercased on
the way in and accepted. Write them in the form they are stored in.
: is deliberately outside the character class, which is what makes the platform's reserved dt: namespace
unforgeable from a sidecar — a mod cannot mint dt:internal and hide an entity from the editor. See
the dt: namespace.
At spawn the list is trimmed, lowercased, de-duplicated and order-preserving, and an entry that still fails
is dropped on its own rather than failing the spawn — so a typo costs you one tag, silently.
metadata#
Merged per key over the definition's own metadata, with your keys winning — and the definition the model
drop builds already carries customModelAssetId, the key that points at the GLB. Set that key in a sidecar
and the entity loads a different model, or none.
Metadata is also where several behaviors that look like top-level fields actually live: the host reads a
container's draw order and capacity, a deck's card entries and snap-grid configuration out of it. That
gives this field the most reach of any in the sidecar and the least checking — unknown top-level sidecar
keys are stripped, but everything nested inside metadata is kept verbatim. Keep to your own keys, and
prefix them so a later platform key cannot collide with one of yours.
materialSlots#
The model's default per-slot material binding: source material name → project material id, merged per key over whatever the spawn definition already carries, with the sidecar winning. The key is the material's name as it appears inside the GLB — never a mesh-instance index, which shifts every time a model is re-exported.
Written once, automatically, when a model is imported. Import lifts the GLB's materials into project materials, writes its embedded images out as project texture files, and then removes those images from the GLB so the bytes are stored once instead of twice. The model keeps its material names, and this record is what reconnects them to the extracted materials — so the model renders as it always did, and editing one of those materials now changes the model.
That makes it load-bearing rather than decorative for an imported model: remove the binding and the mesh falls back to the material the GLB shipped with, which no longer has any textures on it. Per-slot assignments made on an individual entity still win over this — the sidecar sets the default for every spawn, not a rule the author cannot override.
It reaches a scene by two routes, because there are two ways a model is placed. A table entity picks it
up when its spawn definition is built. Room decor (roomPack.decor) never builds one — it is placed by
modelPath — so it reads the same record out of the per-asset authoring registry the sidecar publishes,
alongside collider, triggers and the shadow flags. Anything that draws a model has to consult one of the
two; a placement that consults neither renders with the GLB's bare colours.
castShadows#
The model's default answer to "does this cast a shadow?", applied to every entity spawned from it. Optional: omit the key and the model casts, which is what every model without a sidecar does.
This is the right place to say it once for a model that should never cast — a flat playmat, a
backdrop, a large decorative prop — instead of repeating
castShadows: false on every entity in setup.json. An
individual entity can still override it in either direction; absent on the entity means "use this".
Like collider and
triggers, this is per-asset authoring config. Every peer
reads it from the sidecar the mod tree already carries, and it is never replicated: it does not
appear in a snapshot, a saved game, or a spawn definition. A peer that has not finished pulling the
model yet simply renders shadows on until it resolves, which corrects itself and changes nothing
about physics or gameplay.
receiveShadows#
The model's default answer to "are shadows drawn onto this?", applied to every entity spawned from it. Optional: omit the key and the model receives shadows.
The mirror of castShadows, with the same per-asset,
never-replicated treatment, and independent of it — a table surface typically wants casting off and
receiving on, while a model meant to look self-lit wants the reverse.
Per-entity receiveShadows overrides this; absent on the
entity means "use the model's default".
lightmapStatic#
Declares that this model is permanently-static scenery, so its lighting may be baked into a
lightmap instead of being lit in real time. Optional, and absent means false — a model
without this key is never baked.
Note that the default is the opposite way round from
castShadows and
receiveShadows, which are on unless you turn them
off. Baking is opt-in because a baked object that later moves takes its baked shadow with it and
leaves a shadow behind where it used to be, and neither corrects itself until the next bake. The
test is not "is this static right now?" — a card resting in a hand zone is static right now — but
"will this never move again for the whole session?". Only you can answer that, so only you can set
the flag. Backdrops, wall panels, fixed set dressing and bolted-down scenery qualify; anything a
player can pick up, flip, or drag does not.
The model must carry TEXCOORD_1 (a second UV set, used as the lightmap UVs). Models are
skipped at bake time when they lack it, rather than baking black — so setting lightmapStatic: true on a model with no lightmap UVs does nothing at all. Generate them with the Generate
lightmap UVs action in the Model editor, which unwraps the mesh and writes TEXCOORD_1 into the
GLB.
Like collider,
triggers and the two shadow keys, this is per-asset
authoring config and is never replicated — but for a stronger reason than the others: every player
bakes their own lightmaps locally from their own copy of the scene, so there is nothing for peers
to agree on. It does not appear in a snapshot, a saved game, or a spawn definition.
There is deliberately no per-entity override. Because the flag lives on the asset, placing the same model twice flags both placements; if one copy needs to stay dynamic, use a separate model file for it.
originAuthored#
Declares that this model's origin was placed on purpose, so the runtime must keep it instead of recentring
the model at spawn. Optional, and absent means false — a model without this key gets the recentring every
imported model has always had.
By default an imported model is rested for you: it is recentred on its X/Z bounding-box centre with the bottom of its geometry on the surface. That rule is what makes an arbitrary GLB drop onto the table looking placed rather than floating or half-buried, and it is also what made a hand-placed origin meaningless, because the runtime threw it away. Setting this flag skips the recentring: only the bounds are measured, and the model keeps the origin it was modelled around.
Set it from the Model editor rather than by hand. The editor's origin actions write it alongside the rewritten geometry, and Split writes it on every part it produces, so the pieces of a split model keep the one shared origin that makes them line up again.
⚠ The polarity is opt-in, like lightmapStatic and for the same
kind of reason. Every model published so far was authored under the recentring rule, so a default of true
would let any model whose geometry happens to sit away from its origin — which is most imported scenery — start
spawning in the air or sunk into the table.
Like the other authoring keys in this sidecar, it is never replicated: every peer holds the same GLB bytes and the same sidecar and resolves it locally. A peer that has not pulled the sidecar yet renders the model recentred — visually off by the authored offset until the asset resolves, and self-correcting once it does.
bodyType#
This field and the six after it are flat in the sidecar but land on the spawned entity's physics override,
merged over whatever the definition already carries — that override is what the runtime applies to the
rigidbody and collision components.
Decide the body type first, because it governs whether the rest is read: mass is applied only to a
dynamic body, and a mesh collision shape is refused on one. An imported model always spawns on a static
placeholder body and is promoted to dynamic only once its GLB has attached and the real collider has been
fitted; a static or kinematic value is never promoted, which makes it the honest way to say "this never
simulates" instead of relying on a large mass. See
RIGIDBODY § Body fields for the same choice in the Inspector.
mass#
Kilograms, and read only when the effective body type is dynamic — Ammo gives a static body infinite
mass, so a mass sitting beside bodyType: "static" is discarded without complaint. Absent, a custom
entity spawns at 0.8 kg.
Mass is not derived from scale: doubling a model's size leaves its weight exactly where it was, so a
large model left at the default shoves smaller pieces around like a balloon. Set it in proportion to the
rest of your table rather than to reality — the runtime weighs a card at 0.05 kg and a die at 0.35 kg, and
those are the numbers your piece collides with.
friction#
Coulomb friction against whatever the model is touching. It decides whether a flicked piece grips and stops
or keeps going: at 0 a model skates to the edge of the table on the lightest nudge, and a player who
meant to place it precisely ends up chasing it.
The runtime's default for a custom entity is 0.82 — deliberately high, because most table pieces are
meant to stay where they are put. Lower it for something whose whole point is to slide, such as a
shuffleboard puck; raise it toward 1 for a piece that must not drift when the piece beside it is knocked.
restitution#
Bounciness. This is the physics field that goes wrong quietly: a value that reads as reasonable on its own, say 0.5, makes a token dropped an inch above the board hop off it and land somewhere the player never aimed — which they report as "the piece moved by itself", not as a physics setting.
The runtime's default for a custom entity is 0.1, near dead, while a die runs at 0.52 because tumbling is
the entire point of a die. Raise it only when the bounce is part of the game, and test it by dropping the
piece from the height a player actually drops it from rather than from the Inspector.
linearDamping#
Bleeds straight-line speed out of the body on every simulation step, whether or not it is touching
anything. That is what separates it from friction: friction slows a piece that is sliding on a surface,
damping also slows one that is in the air.
The runtime's default for a custom entity is 0.05. Raise it when a nudged model coasts further than a
tightly packed board can tolerate. Close to 1 the model stops almost the instant it is released, which
players read as treacle rather than as weight, so move in small steps and watch a throw before committing.
angularDamping#
The rotational twin of linearDamping: it bleeds spin rather than travel. It is the fix for a model that
keeps slowly pirouetting for a second after a player has set it down — nothing is broken, but it looks like
something is.
The runtime's default for a custom entity is 0.08, and a die runs at 0.015: a die whose spin died on
contact would never tumble, so the low value there is the feature, not an oversight. Raise it for tall,
top-heavy models that should settle rather than wobble, and leave it low for anything meant to roll.
collisionShape#
auto means "keep the collider the runtime already fitted", which for an imported model is a box matched
to the model's measured bounds. Override it only when that box is visibly wrong — an arch a piece should
drop through, a bowl, an L-shaped tile — because every other value costs more to simulate and forces a
rebuild of the Ammo body when it changes.
The Model tab no longer offers this field. The authored
collider list supersedes it — it says everything this key
says and adds a per-entry transform, several shapes at once, and a mesh source. The key stays
valid, stays parsed, and is projected into the collider list for display, so an existing sidecar
keeps working with no migration and no rewrite; there is simply no longer a control that writes
it. Prefer collider in anything new.
convexHull and mesh are built from the GLB's meshes, so they cannot be applied until the model has
attached; the runtime re-applies the override at that moment and skips the request entirely while no mesh
with usable geometry is there. mesh on a dynamic body silently becomes convexHull, because Ammo's
triangle-mesh shape is valid only for a static body — a genuinely concave collider needs
bodyType set to static. See
COLLISION § Shape.
collider#
The model's authored collider — the shape the physics engine actually collides with, which is not the model's visible geometry and never has been. Optional: omit the key and every spawned instance keeps the collider the runtime already fits, which for an imported model is a box matched to its measured bounds.
It is a list, not a single shape, and it is a discriminated union on mode:
mode |
Shape |
|---|---|
"auto" |
{ "mode": "auto" } — the empty state. Keep whatever the per-kind default configures. Equivalent to collisionShape: "auto", and a real value rather than an absence so the editor can show it as a row and draw it honestly. |
"custom" |
{ "mode": "custom", "entries": [ … ] } — 1 to 8 entries, assembled into one compound collider. |
Each entry carries an id (1–64 chars, yours, unique within the list), an optional name (1–64 chars, for the
editor's list), and its own transform:
| Key | Type | Meaning |
|---|---|---|
offset |
{ x, y, z } |
Centre offset from the entity's origin, in object-local feet. Omit it and the entry is centred on the model's measured centre. |
rotation |
{ x, y, z } |
Euler XYZ in degrees. |
scale |
{ x, y, z } |
A multiplier applied on top of the resolved dimensions, not a replacement for them. |
Then one shape, with its own fields:
shape |
Fields |
|---|---|
"box" |
size — the full dimensions in object-local feet, all three positive. |
"sphere" |
radius |
"capsule" |
radius, height (the cylindrical section only, excluding the two caps), axis (0/1/2, default 1 = Y) |
"cylinder" |
radius, height (the whole height), axis (default 1) |
"convexHull" |
source ("render" | "baked", default "render"), bakedPath, simplifyRatio (0–1], maxError (0–1) |
"mesh" |
the same four fields as convexHull |
Every length is in feet, measured at the entity's identity scale — the same convention as
collisionSize — and the entity's own scale is applied on top at
spawn. The world unit in DiceyTable is a foot; a playing card is about 0.29 ft wide.
source: "baked" reads a collider mesh from a sibling GLB next to the model: strip the model file's last
extension and append .collider.glb, so assets/models/keep.glb pairs with assets/models/keep.collider.glb.
Set bakedPath when the file lives somewhere else. Bake when the render mesh is too heavy to collide against —
a collider mesh is budgeted at 2,000 triangles, far below the render budget, because the hull builder feeds
every vertex of the source into the shape with no reduction at all and each one costs time per contact.
convexHull and mesh cannot be built until the model's geometry has attached, and a mesh collider on a
dynamic body falls back to a convex hull — a triangle-mesh shape is valid only for a static body. See
COLLISION § Shape for that and the two other conditions the runtime
refuses a mesh-derived collider under.
An eight-entry list is a compound of eight shapes, which is how a concave model gets an honest collider without a triangle mesh: three boxes around an arch cost far less than the arch's geometry and let a piece pass under it.
Both this key and triggers are read by every peer from the model's own
sidecar. Neither one is replicated per instance and neither appears in a snapshot.
triggers#
Up to eight trigger volumes authored on the model — invisible shapes that report when something crosses into or out of them. Optional; omit the key and the model has none.
A trigger volume never collides and never affects physics. It has no mass, it stops nothing, and it changes no simulation result. Firing two events is the entire whole of what it does:
globalEvents.onTriggerEnter/onTriggerLeavefor a table scriptonTriggerEnter/onTriggerLeavefor a mod, which needs theread-worldcapability as well assubscribe-events
And they only do anything if a script or a mod subscribes. Authoring a volume on a model nobody wrote a rule for changes nothing at all. A volume never names code to run — the relationship only runs from the volume's geometry to a handler somebody registered.
Both events are raised by the host and nowhere else: the host samples membership on a fixed low-rate pass and reports the differences. A handler on a player's or spectator's client never runs.
Every volume is { id, name, shape, position, rotation, size, tag? }. Positions and sizes are in object-local
feet at the entity's identity scale, rotations are Euler XYZ in degrees, and size is full
dimensions rather than half-extents for every shape. Containment is tested at the crossing entity's origin
point, exactly as seat-zone membership is, so a large piece is inside when its origin is — not when its
geometry overlaps.
Volumes are never persisted and never replicated. Each peer rebuilds them from this sidecar when it loads the model, and only the host's copies are sampled.
{
"triggers": [
{
"id": "goal-slot",
"name": "Goal Slot",
"shape": "box",
"position": { "x": 0, "y": 0.15, "z": -0.4 },
"rotation": { "x": 0, "y": 0, "z": 0 },
"size": { "x": 0.35, "y": 0.3, "z": 0.2 },
"tag": "goal"
}
]
}
containerInterior#
The model's interior volume — where the cavity of a bowl, tray or bag actually is, in object-local feet.
Authoring configuration on the asset, exactly like collider and
triggers: every peer resolves it from the same sidecar bytes, it never appears
in a snapshot, and it is never replicated per instance. Optional — omit it and one is derived (below).
Four things read it, which is why it is one shape rather than four settings:
- The fill. The "looks full" heap an infinite container shows is packed inside this volume, with its top
surface at
fillLevel. - The drop footprint. Whether a released piece counts as "in" the container is tested against this volume's footprint.
- The generated collider. The editor's container-shell generator builds a closed shell whose inner surface is exactly this volume, thickening the walls outward and downward so a thin bowl still stops a fast piece. Nobody can see the outside of a locked container's collider, so the thickness goes there.
- The editor's holder
Fill. Filling an open holder in the Inspector spawns real pieces, packed into this volume up tofillLevel— so a short fill ("Placed 40 of 180") is a statement about this volume.
It is deliberately not an entry in triggers[], although it borrows that shape's geometry conventions. A
trigger is realised as a real physics trigger entity with script hooks and an author-visible tag, none of
which an interior wants, and the consumers above are not trigger events.
If you omit it, one is derived from the collider's bounds — footprint inset 12 %, floor at 15 % of the
height, top at the bounds' top (a container is open, so its rim is its highest point), fillLevel 0.85, and
cylinder rather than box when the footprint is near-square and the collider is made of round primitives.
The derived volume is always strictly inside the collider, so a fill drawn in it cannot poke through the
model — but it is visibly approximate, and the editor labels it as derived. Author one when the fit matters.
{
"containerInterior": {
"shape": "cylinder",
"position": { "x": 0, "y": 0.18, "z": 0 },
"rotation": { "x": 0, "y": 0, "z": 0 },
"size": { "x": 0.7, "y": 0.3, "z": 0.7 },
"fillLevel": 0.85
}
}
modelAssetMetaSchema.rotation#
| Field | Type | Required | Default | Min / Max | Pattern | Rule | Description |
|---|---|---|---|---|---|---|---|
x |
number |
yes | — | — | — | — | — |
y |
number |
yes | — | — | — | — | — |
z |
number |
yes | — | — | — | — | — |
rotation.x
Pitch, in degrees, about the horizontal axis running left to right across the table. It tips the model forward or backward around the point it stands on, so any non-zero value lifts one edge clear of the surface and pushes the opposite edge through it.
180 here is the runtime's own convention for a card turned over, which makes it a useful check on your
export: if you need it to get an imported model sitting the right way up, the model came out of your
modeling tool upside down and the cheaper fix is there.
rotation.y
Yaw, in degrees, about the vertical axis. It is the only one of the three you can set without changing how the model meets the table — the model spins in place and still rests flat — which makes it the safe one and the one worth setting deliberately.
Use it for a directional piece: a miniature that should face down the table, an arrow, a one-way tile. Players rotate pieces constantly during play, so treat this as the orientation the model arrives in rather than one it is going to keep.
rotation.z
Roll, in degrees, about the remaining horizontal axis: it leans the model sideways instead of tipping it forward. As with pitch, any non-zero value takes part of the base off the table surface.
A dynamic body does not hold that lean — gravity settles it back onto whatever face its collider actually
rests on, and a player sees a piece that fell over during load. If the lean is the look you want, pair it
with locked or a static bodyType; otherwise keep this at zero and do your orienting with
rotation.y.
modelAssetMetaSchema.triggers#
| Field | Type | Required | Default | Min / Max | Pattern | Rule | Description |
|---|---|---|---|---|---|---|---|
id |
string |
yes | — | 1–64 chars | — | — | — |
name |
string |
yes | — | 1–64 chars | — | — | — |
shape |
"box" | "sphere" | "capsule" | "cylinder" |
yes | — | — | — | — | — |
position |
object |
yes | — | — | — | — | — |
rotation |
object <br>same shape as triggers.position |
yes | — | — | — | — | — |
size |
object |
yes | — | — | — | — | — |
tag |
string |
no | — | <= 32 chars | — | Expected 1-32 characters matching /^[a-z0-9_-]+$/i and not the reserved "dt:" prefix. | — |
triggers.id
The volume's identity, 1–64 characters, chosen by you and required. It is what a handler receives as
triggerId, so keep it stable: renaming it breaks any rule
that matched on it, silently, with no error anywhere.
It is unique only within this model asset. Two spawned copies of the model both report the same triggerId
for the same volume — which is fine, because the payload also carries ownerObjectId, the entity the volume is
on. A handler tells two copies apart by keying on the pair. Your job is only to keep the id unique among
this model's volumes.
Use a slug you would be happy for a script author to type as a literal — goal-slot, card-well, space-14 —
rather than trigger-3.
triggers.name
The human-readable label, 1–64 characters, required. It reaches a handler as
triggerName and is the one field on the event payload
written for a person: it is what a log line or a UI label prints.
It is not an identifier. Nothing stops two volumes sharing a name, and editing it is expected — which is
exactly why a script should match on tag or
id and print this.
triggers.shape
Which primitive the volume is: box, sphere, capsule or cylinder. Required.
Primitives only, deliberately. A trigger volume answers a containment question about a single point, so a convex hull or a triangle mesh would cost real geometry to answer a question a box answers exactly — and unlike a collider there is no accuracy to gain, because nothing ever collides with a volume.
The shape decides how size is read: a box uses all three dimensions,
a sphere takes its radius from the largest half-dimension, and a capsule or cylinder takes its radius from
the two horizontal dimensions and its height from size.y. Capsules and cylinders are always Y-axis volumes;
rotate them with rotation if you need another orientation.
Reach for box unless the shape is genuinely round. It is the cheapest test and the easiest to reason about
against a rectangular model.
triggers.position
The volume's centre, in object-local feet at the entity's identity scale, measured from the entity's origin. Required — a volume always states where it is.
The origin of an imported model is the point it stands on: the runtime rests the model with its X/Z centre on the
origin and its lowest point at y = 0. So { "x": 0, "y": 0, "z": 0 } puts the volume's centre at the surface
the model sits on, and half of a box volume there hangs below the table. Lift it by half the volume's height to
have it sit on the model's footprint.
The entity's own scale is applied on top at spawn, so a volume authored at unit scale follows a scaled-up
instance without editing.
Remember the unit. A playing card is about 0.29 ft wide, so a slot that holds one is a fraction of a foot across,
not 1.
triggers.rotation
The volume's orientation as Euler XYZ angles in degrees. Required, and { "x": 0, "y": 0, "z": 0 } is the
ordinary answer.
It rotates the volume about its own position, not about the
entity's origin, so a yaw spins the volume in place. Use it to line a volume up with a slot the model author drew
at an angle, and to orient a capsule or cylinder, both of which are built as Y-axis volumes before this
rotation is applied.
Rotating a sphere does nothing measurable, which is worth knowing only so that a stray value on one is not read
as a bug elsewhere.
triggers.size
The volume's full dimensions in object-local feet — not half-extents — all three strictly positive. Required.
How each shape reads it:
shape |
How size is used |
|---|---|
box |
All three, directly: the box is size across. |
sphere |
Radius = the largest of the three half-dimensions. The other two are ignored. |
cylinder |
Radius = the larger of the x and z half-dimensions; height = size.y; axis = Y. |
capsule |
Radius as for a cylinder; height = size.y minus the two caps (size.y − 2 × radius, floored at 0), matching the engine's capsule convention. A capsule whose size.y is no more than its diameter is therefore a sphere. |
The entity's scale multiplies these at spawn, so author at unit scale and let instances scale.
Make a volume comfortably larger than the thing meant to cross it. Containment is tested at the crossing entity's origin point, so a volume the exact size of a card's footprint reports a crossing only when the card's origin is inside it — which is a much smaller target than the card looks.
triggers.tag
The identifier a script or mod should match on, and the reason the tag exists. Optional: a volume without one can
still be recognised by id, but a tag is the field that says "this
volume is here for a rule".
It follows the platform's shared author-tag rules — 1–32 characters matching ^[a-z0-9_-]+$ — and the reserved
dt: namespace is rejected, validated with the same isUserTag check every other author tag goes through.
: is outside the character class, which is what makes the platform namespace unforgeable from a sidecar. See
the dt: namespace.
A trigger tag is not an entity tag. It lives on the volume, never on the entity, so it never appears in
tags, in TableObjectState.tags, or in any tag filter.
Reuse a tag deliberately: several volumes sharing one tag is how a model says "any of these counts as a goal", and a handler matching that tag then fires for all of them without knowing how many there are.
modelAssetMetaSchema.containerInterior#
| Field | Type | Required | Default | Min / Max | Pattern | Rule | Description |
|---|---|---|---|---|---|---|---|
shape |
"box" | "cylinder" | "sphere" |
yes | — | — | — | — | — |
position |
object <br>same shape as triggers.position |
yes | — | — | — | — | — |
rotation |
object <br>same shape as triggers.position |
yes | — | — | — | — | — |
size |
object <br>same shape as triggers.size |
yes | — | — | — | — | — |
fillLevel |
number |
no | 0.85 |
>= 0, <= 1 | — | — | — |
containerInterior.shape
The cavity's shape: box, cylinder or sphere. Required.
box— a tray, a crate, a square well. The volume issizeexactly.cylinder— a round bowl with straight sides. The radius comes from the larger of the two off-axis dimensions and the height fromsize.y.sphere— a bowl, not a ball. The cavity is a sphere cut open at the top of the declared box, so a squatsizelike{ "x": 1, "y": 0.6, "z": 1 }gives a wide-mouthed bowl with a rounded bottom. Its radius comes from the largest half-dimension.
The sphere case has one clamp worth knowing: the rim is never cut higher than 0.8 of the radius above the
centre. Without it, a cube-ish size would cut the sphere at its own north pole and produce a closed ball
with a pinhole — a cavity nothing can get into. A squat, bowl-shaped size is unaffected.
Use sphere when pieces should settle toward the middle, cylinder or box when they should spread evenly
across a flat floor. The shape is also what the generated collider shell is built from, so it decides how the
inside of the container feels, not just how the fill looks.
containerInterior.position
The centre of the cavity, in object-local feet — the same convention as a trigger volume's position and
a collider entry's offset, resolved by the same code.
It is the centre of the volume, not its floor and not its rim: a cavity 0.3 ft deep whose floor should sit
0.03 ft above the model's origin has y: 0.18. The world unit here is a foot, and a playing card is about
0.29 ft wide, so most bowl interiors are fractions of one.
Measured at the model's identity scale. An entity's own scale is applied on top at spawn, so a bowl
placed at 2× has an interior twice the size without any change here.
containerInterior.rotation
The cavity's orientation, Euler XYZ in degrees — again the trigger volume's convention.
Usually all zeros. A container's cavity opens upward, and tilting one tilts the surface the fill sits on and
the footprint a dropped piece is tested against, so reach for it only when the model itself is authored at an
angle — a tipped bowl, a sloped hopper — rather than to nudge the fit of an upright one. Move
position or change size for that.
containerInterior.size
The cavity's dimensions in object-local feet, all three strictly positive.
These are FULL dimensions, not half-extents — the same convention as a trigger volume's size and a box
collider's size. A well 0.7 ft across and 0.3 ft deep is { "x": 0.7, "y": 0.3, "z": 0.7 }, not 0.35. Getting
this wrong by a factor of two is the single most common authoring mistake here, and it shows up as a fill that
either floats out of the bowl or hides at the bottom of it.
How the non-box shapes read it: a cylinder takes its radius from the larger of x and z and its height
from y; a sphere takes its radius from the largest half-dimension and is then cut open at +y/2 to make a
bowl.
Size the cavity to the visible inside of the model. The generated collider shell then puts its inner surface exactly here and grows the wall thickness outward and downward, so pieces come to rest where they look like they should.
containerInterior.fillLevel
Where the "looks full" fill's top surface sits, as a fraction of the interior height measured up from its
floor. 0–1, defaulting to 0.85.
It exists so the four geometric keys can be authored without a fifth decision, and so a full bowl reads as nearly full rather than brim-level — a heap that reaches the rim looks like it is about to spill, and any piece resting on top of it looks wrong.
Only an infinite container draws a fill, so on a finite bowl or tray this value changes nothing you can see. It is still worth setting on the asset: the same model is usually used for both, and the value travels with the model rather than with the placement.
0 puts the surface on the floor (an effectively invisible fill) and 1 puts it at the rim. Between about
0.75 and 0.9 is the range that looks like a full container.
modelAssetMetaSchema.triggers.position#
| Field | Type | Required | Default | Min / Max | Pattern | Rule | Description |
|---|---|---|---|---|---|---|---|
x |
number |
yes | — | — | — | — | — |
y |
number |
yes | — | — | — | — | — |
z |
number |
yes | — | — | — | — | — |
triggers.position.x
The volume centre's offset along the horizontal axis running left to right across the table, in object-local feet from the entity's origin. Positive is one way, negative the other; which way is whichever way the model was exported, so check it in the Model editor rather than reasoning about it.
Zero is the model's own X centre, because the runtime rests an imported model with its X/Z centre on the origin.
Leave it at 0 for anything centred, and use it to place one volume per column when a board carries several.
triggers.position.y
The volume centre's height, in object-local feet above the plane the model stands on — an imported model
is rested with its lowest point at y = 0, so 0 here is the table surface under it, not the model's middle.
This is the value most often wrong on a first attempt. A box volume centred at 0 has half its height below the
surface, so a rule that should catch a piece resting on the model fires from underneath it too. Set it to at
least half the volume's size.y to sit the volume on the footprint,
and higher again to catch something dropped into a well.
Volumes have a ceiling, unlike seat zones: a piece lifted above a volume's top face is outside it. If you want "anywhere above this spot", make the volume tall on purpose.
triggers.position.z
The volume centre's offset along the horizontal axis running toward and away from the table's front edge, in object-local feet from the entity's origin.
Zero is the model's own Z centre. Pair it with
position.x to place a volume anywhere on the model's footprint —
one per space on a track, one per slot on a board — and remember both are measured before the entity's scale is
applied, so a volume authored at unit scale follows a scaled instance.
modelAssetMetaSchema.triggers.size#
| Field | Type | Required | Default | Min / Max | Pattern | Rule | Description |
|---|---|---|---|---|---|---|---|
x |
number |
yes | — | > 0 | — | — | — |
y |
number |
yes | — | > 0 | — | — | — |
z |
number |
yes | — | > 0 | — | — | — |
triggers.size.x
The volume's full width in object-local feet, strictly positive — the whole span, not a half-extent.
For a box it is used directly. For a cylinder or capsule it is one of the two dimensions the radius comes
from (the larger half of x and z wins). For a sphere it competes with y and z for the largest
half-dimension and is otherwise ignored.
Size it for the crossing entity's origin point, not its silhouette: containment is a point test, so a volume exactly as wide as a card gives you a target the width of the card only if the card is perfectly centred. Err wide.
triggers.size.y
The volume's full height in object-local feet, strictly positive.
It is read differently per shape, and the capsule case surprises people:
box— the height, directly.cylinder— the height, directly. The axis is Y.capsule— the overall height, from which the two hemispherical caps are subtracted to get the cylindrical section (size.y − 2 × radius, floored at zero). A capsule no taller than it is wide is therefore just a sphere.sphere— competes withxandzfor the largest half-dimension.
A volume has a top face, so this is what decides whether a piece hovering above the model is inside or outside. Seat zones deliberately have no ceiling; trigger volumes do. Make it tall when the rule is "somewhere over here" and shallow when the rule is "resting in this slot".
triggers.size.z
The volume's full depth in object-local feet, strictly positive.
For a box it is used directly; for a cylinder or capsule it is the other dimension the radius is taken from
(the larger half of x and z); for a sphere it competes for the largest half-dimension.
As with size.x, size it generously: the crossing test is against the
entity's origin point, so a volume matched exactly to a piece's footprint is a much smaller target than it looks.
