Dicey Table

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}).` (code invalid-model-meta)
  • Invalid deck definition: `Custom deck definition does not match the expected schema (${field}: ${message}).` (code invalid-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}).` (code invalid-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 }
    }
  ]
}
  • position places 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.
  • rotation is in degrees. rotation and scale default to identity. Scale must be positive.
  • model is a repo-relative .glb/.gltf path from the repo root, not from the assembly's own folder. A part cannot be another assembly.
  • parts holds 1–256 entries, and each id must 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. A convexHull or mesh entry with "source": "baked" reads its geometry from a 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. bakedPath overrides that when the file lives elsewhere. Declare the collider GLB in assets[] like any other asset.
  • triggers[].tag cannot be a platform tag. It goes through the same author-tag validation as every other tag — 1–32 characters matching ^[a-z0-9_-]+$ — and a dt:-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.

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.webptextures/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.json too.
  • 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 unlocked derivedFrom credit.

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>.webpmodels/keep.glbthumbnails/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: isDeclarableModAsset excludes everything under thumbnails/. 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 under thumbnails/ yourself — say thumbnails/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 under thumbnails/ 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: isDeclarableModAsset requires an allowed file extension, and .keep has 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 in assets[] to be fetched and validated. The editor declares it for you; a hand-built repo has to list it.
  • COLLISION — the shape options collider authors, and the three conditions the runtime refuses a mesh-derived collider under.
  • onTriggerEnter (table script) and onTriggerEnter (mod) — the only two things a triggers entry 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 backIsHidden behaviour. 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. backIsHidden is 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 surfacewood, 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:

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:

  1. The fill. The "looks full" heap an infinite container shows is packed inside this volume, with its top surface at fillLevel.
  2. The drop footprint. Whether a released piece counts as "in" the container is tested against this volume's footprint.
  3. 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.
  4. The editor's holder Fill. Filling an open holder in the Inspector spawns real pieces, packed into this volume up to fillLevel — 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 is size exactly.
  • cylinder — a round bowl with straight sides. The radius comes from the larger of the two off-axis dimensions and the height from size.y.
  • sphere — a bowl, not a ball. The cavity is a sphere cut open at the top of the declared box, so a squat size like { "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. 01, 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 with x and z for 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.