Object state
TableObjectState is a Scene Entity's full
serialized state: what a TableSnapshot carries per entity, what replicates to every peer,
and what the persistence layer saves. Its schema is tableObjectStateSchema in
packages/shared/src/tableObjects.ts. TableSnapshot also carries 7 sub-collections
for things that are not table objects — zones, snap points, vector lines, decals, text
labels, joints, and world-space UI — covered in the second half of this page.
packages/sharedtype names were not renamed when the editor's user-facing labels changed (D-3 of the ECS work).TableObjectState,label,displayNameand every other identifier below keeps its schema name even where the Inspector shows different English — see ECS terminology.
The three names, at the schema level#
Every entity carries three distinct name-shaped fields. Full treatment, including the
slug-normalization rules and the Name/Slug field split, lives at
IDs, names and tags; this section is the
field-by-field summary.
| Field | Schema type | Required | Addresses the entity? |
|---|---|---|---|
id |
string, 1–96 chars |
yes | Yes — the only one that does. |
label |
string, 1–80 chars |
yes | No. The slug: unique in the scene, machine-facing, and for kind: "card" the card's identity — it is what drives hidden-information redaction. |
displayName |
string, 1–80 chars, optional |
no | No. The human name. Falls back to label when absent; objectDisplayName() is the one resolver every surface (Hierarchy row, entity name, status line, context menu) uses so they can never disagree. Redacted with the card: deleted from any copy — on the wire or from a mod read — whose viewer is not entitled to that card's identity. |
Nothing — no script, no mod, no runtime lookup — resolves an entity by label or
displayName. id is assigned on creation and never edited by hand.
secretMetadata — the place for a secret#
metadata is public: apart from the two identity keys (cardId on a card, cards on a container)
it is identical in every copy of the snapshot. secretMetadata is the same shape
(Record<string, unknown>, any JSON value per key) with one difference — the host removes it
outright from any copy whose viewer is not entitled to the entity's identity. It exists so a card's
real identity has a home that is not the human-readable Name.
| Kind | Who receives it |
|---|---|
card |
Exactly whoever may see the card's face: it is face-up, or in the viewer's own hand, or revealed to the viewer's team. |
deck |
The host only, always. |
| every other kind | The host only, always. |
A mod is not "whoever". Since 2026-08-14 the six read-world reads answer as the
least-privileged viewer — a spectator with no seat and no team — on every peer including the host,
so a mod sees secretMetadata on exactly one thing: a card whose face is already public. Read the
table above as the wire rule; the mod rule is one line, and it is "no".
The card rule is the same predicate that governs label and metadata.cardId
(isCardIdentityVisibleToViewer) — there is deliberately no second notion of visibility to drift
out of sync with it. The deck and other-kind rule is the closed one on purpose: a deck has no
per-viewer identity concept at all (its ordered contents are withheld from every peer, D2), so
there is no entitlement to derive an answer from, and "host only" is the answer that cannot be
wrong. That also means the field can never be accidentally public on a kind nobody thought about.
Four things to know before you use it:
- Capped at 2 048 bytes of JSON per entity (
MAX_SECRET_METADATA_BYTES), measured on the UTF-8 encoding ofJSON.stringify(value). Over the cap the object fails schema validation. It rides on every entity of a snapshot capped at 5 000, which is why it is an order of magnitude tighter than the 16 KiB mod saved-data cap; anything bulky belongs inmodObjectSavedData. - Absent, never
{}. An entity without a secret has no such field, so an empty bag can never enter a snapshot delta as a spurious change. - It is a replication boundary, not encryption. The host's runtime holds it in full, and it is written to the save unredacted — a saved game keeps its full hidden state, so treat a save file as you would the host's own memory. Redaction is applied on the way out of the host: at the wire boundary, and — since 2026-08-14 — at the mod read boundary as well.
- A mod running on the host does not read it. That was true until 2026-08-14 and is the single
most likely thing to trip up an existing mod.
read-worldno longer grants a frame the peer's own view; the elevated read isapi.getUnredactedSnapshot, gated by theread-hidden-informationcapability, which a player can read in the mod's manifest. - A face-up card's secret is public. The gate is the identity entitlement, not a separate switch. Anything that must stay hidden after a reveal does not belong here.
Absence is ambiguous: it means either "the author set none" or "you were not entitled", and nothing distinguishes those. Write every mod rule to tolerate its absence, wherever the mod is running.
migrateTableSnapshot() prunes an over-cap or non-object secretMetadata on load rather than
rejecting the snapshot — the same policy as parentId below, for the same reason: a hand-edited
save must never be able to wedge a room.
Field reference#
Fields not already covered by Object kinds (which
covers the kind-specific meaning of stackCount, containerMode, capacityLimit,
faceDown, metadata) or the three-names table above:
| Field | Type | Notes |
|---|---|---|
kind |
TableObjectKind |
See Object kinds. |
position, rotation, scale |
Vector3Tuple |
Always world-absolute on the wire, even for a parented entity — every consumer (peers, mods, listObjects, the TTS/TTP bridges, saved games) already reads them as world, so keeping them local would make reconstruction order-dependent. |
color |
string, #rrggbb |
Hex color; interpretation is kind-dependent (tint vs. accent). |
ownerSeat |
string | null, ≤40 chars |
The seat holding this entity in-hand. Any object with a non-empty ownerSeat counts as a hand card in getHandObjectsFromSnapshot, regardless of kind. |
locked |
boolean |
Governs the locked-entity action gate. Switches the rigidbody to static while locked; unlock restores the authored body type. |
tapped |
boolean, optional |
Set by the tap/untap actions. Not gated by isObjectActionAllowedForTarget for any kind today — see Object actions. |
tags |
string[], ≤100, each [a-z0-9_-] 1–32 chars |
Author tags. The dt: prefix is reserved and unauthorable — see below. |
parentId |
string | null, ≤96 chars, optional |
The parent Scene Entity. Transform/organization only, never a physics constraint. null explicitly means "detached"; absent means "never parented" — the distinction matters for delta comparison (strictEq, which treats null and undefined as different values here). |
physics |
ObjectPhysics, optional |
Per-object overrides (bodyType, mass, friction, restitution, linearDamping, angularDamping, collisionShape, collisionSize, collisionOffset, rigidbodyEnabled, collisionEnabled, weldChildren). Every field absent = use the runtime's per-kind default (see Object kinds). |
components |
ObjectComponentState[], optional |
Optional engine components — see below. |
material |
SoundMaterial, optional |
One of the 8 sound materials; drives both the default sound set and (via defaultObjectPhysicsForKind's suggestion path) editor placeholder physics. |
soundSetOverrides |
Partial<Record<SoundAction, SoundRef>>, optional |
Per-SoundAction overrides; each value is a license-safe SoundRef, never a raw clip id. |
metadata |
Record<string, unknown> |
The structured extension point, and public apart from the two identity keys — cardId is deleted and cards reduced to the publicly-visible front entry for a viewer not entitled to them, with __redacted: true set. Kind-specific data lives here — see Object kinds for what each kind actually reads. Keep it small: it rides in every snapshot. |
secretMetadata |
Record<string, unknown>, optional, ≤2 KiB of JSON |
The non-public bag: stripped from any viewer not entitled to the entity's identity, which for a mod read means everyone. See above. |
velocity, angularVelocity |
Vector3Tuple |
Live physics state, present only on TableObjectState (not TableObjectDefinition — you cannot spawn an entity with a starting velocity). |
Optional engine components#
components[] is capped at 8 entries (MAX_OBJECT_COMPONENTS) with at most one per
type. The addable set today is light and camera (OBJECT_COMPONENT_TYPES) — not
script, which stays on metadata.scriptId with its own section, and not render,
collision or rigidbody, which are intrinsic to every entity and are configured through
physics/metadata.materialId rather than listed here. See
ECS terminology §4 for the two-tier reasoning.
light—{ type: "directional" | "omni" | "spot", color, intensity, range, innerConeAngle, outerConeAngle, castShadows }, defaults to enabled.camera—{ clearColor, fov, nearClip, farClip, projection, orthoHeight, priority }, defaults to disabled on add and itspriorityis clamped by the runtime to stay below the table camera — a second enabled camera changes what the whole table renders through, so enabling one is a deliberate second action.
A component payload with a type this client does not recognize is rejected at the
schema boundary, not silently dropped — silently dropping it would let an older client
resave the entity and permanently discard a newer client's authoring.
The reserved dt: tag namespace#
Tags beginning dt: are platform-owned and cannot be authored: OBJECT_TAG_PATTERN
(^[a-z0-9_-]+$, case-insensitive) excludes :, so a setup file declaring "dt:internal"
fails schema validation outright rather than being accepted and ignored. They still appear
in anything that reads an entity's tags. Full list and rationale at
IDs, names and tags.
parentId and persistence — read this before assuming data loss#
TableSnapshot itself carries no explicit schema-version field — the "v2" label lives
only in code comments and the migrateTableSnapshot() function name; everything ECS added
to TableObjectState (parentId, components, displayName,
physics.rigidbodyEnabled/collisionEnabled/weldChildren) is optional and additive on
top of that unversioned wire shape, so no migration bump was needed. secretMetadata landed
the same way — optional and additive, so an older save parses unchanged and there was again no
version bump.
migrateTableSnapshot() (packages/shared/src/tableObjects/migrations.ts) does one thing
to parentId on every load: it prunes, never backfills. Self-parents, dangling parent
references, cycles, and chains deeper than the configured maximum are all rewritten to
null before the snapshot is even parsed, so a hand-edited or corrupted save can never wedge
the runtime in an infinite parent walk.
On native (DiceyTable-format) export/import, parentId is lossless — the full schema
parses it like any other field. It is only the older, format-agnostic TTS/TTP bridge that
has a narrower field set, and that importer was recently widened (not narrowed) to also
carry parentId, displayName, tags, tapped, physics, material,
soundSetOverrides, components, containerMode, capacityLimit and secretMetadata where
the source data has them. Export is unredacted by design — a save keeps its full hidden
state — so an exported file carries every entity's secretMetadata in the clear. Treat a save
file as you would the host's own memory.
The 7 sub-collections#
TableSnapshot carries seven optional arrays beyond objects[], each with a matching
*DefinitionSchema (spawn-time input) and *StateSchema (full serialized form) pair —
mirroring the TableObjectDefinition / TableObjectState split. All seven schemas live in
packages/shared/src/tableObjects.ts.
Zones#
A seat/interaction rectangle on the table (tableZoneDefinitionSchema /
tableZoneStateSchema). id, label, position, size: { x, z } (each 0 < value ≤ 24),
rotationY, ownerSeat, interaction (allow / owner-seat-only / blocked),
visibility (all / owner-seat-only), metadata.
Snap points#
A point that pulls a dropped entity into alignment (tableSnapPointDefinitionSchema /
tableSnapPointStateSchema). id, label (≤80, optional), position, rotationY,
snapRadius (0 < value ≤ 2), ownerSeat, metadata.
Vector lines#
A drawn/annotation line (tableVectorLineDefinitionSchema / tableVectorLineStateSchema).
id, points (2–1000 Vector3Tuples), color (#rrggbb), thickness (0 < value ≤ 10),
rotation, metadata.
Decals#
A projected image on a surface (tableDecalDefinitionSchema / tableDecalStateSchema).
id, name (≤80), url (≤2048), position, rotation, scale, metadata.
Text labels#
Floating 3D text (tableTextLabelDefinitionSchema / tableTextLabelStateSchema). id,
text (≤256), position, scale (0 < value ≤ 5), color, fontSize (0 < value ≤ 100),
metadata.
Joints#
A physics constraint between two independently dynamic bodies
(tableJointDefinitionSchema / tableJointStateSchema) — never parenting; see
the parenting/joint/weld split. id,
type (fixed / hinge / spring), objectAId, objectBId, enableCollision,
breakForce (0 < value ≤ 1,000,000), metadata.
UI#
World-space or screen-anchored mod UI (tableUiElementDefinitionSchema /
tableUiElementStateSchema, collected under tableUiStateSchema with a revision
counter). id, parentId (nesting within the UI tree, unrelated to entity parentId),
type (text / button / checkbox / input / panel / canvas / layout), order,
ownerSeat, visibility (all / seat / team / players, each scoped to a list),
presentation (world, or screen with an anchor + pixel offset), layout, props
(shape validated per type via a discriminated union), metadata, ownerModId.
See also#
- Limits and caps — the size cap on every array on this page.
- Object kinds — kind-specific field meaning.
- IDs, names and tags — the full treatment of
id/label/displayName. - Mod scripting API —
TableObjectState, the same fields as the mod declarations state them. - Mod hooks and capabilities —
ModTableEventPayload.object, where a hook hands you one of these. ObjectHandle—ObjectData, the table-script view of the same entity, which is a different and smaller shape.- Types —
SpawnObjectOptions, the table-script side of creating one.
tableObjectDefinitionSchema#
Exported from @diceytable/shared as tableObjectDefinitionSchema. 54 fields across 6 tables.
| Field | Type | Required | Default | Min / Max | Pattern | Rule | Description |
|---|---|---|---|---|---|---|---|
id |
string |
no | — | 1–96 chars | — | — | — |
kind |
"card" | "deck" | "die" | "token" | "board" | "bag" | "custom" | "card-holder" | "button" |
yes | — | — | — | — | — |
label |
string |
yes | — | 1–80 chars | — | — | — |
position |
vector3TupleSchema |
yes | — | — | — | — | — |
rotation |
vector3TupleSchema <br>same shape as position |
no | — | — | — | — | — |
scale |
vector3TupleSchema <br>same shape as position |
no | — | — | — | — | — |
color |
string |
no | — | — | ^#[0-9a-f]{6}$ |
— | — |
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_-]+$ |
— | — |
components |
objectComponentSchema[] |
no | — | <= 8 items | — | 1 further cross-field rule (message built at validation time). | — |
displayName |
string |
no | — | 1–80 chars | — | — | — |
physics |
objectPhysicsSchema |
no | — | — | — | — | — |
castShadows |
boolean |
no | — | — | — | — | — |
receiveShadows |
boolean |
no | — | — | — | — | — |
material |
soundMaterialSchema |
no | — | — | — | — | — |
soundSetOverrides |
soundSetOverridesSchema |
no | — | — | — | — | — |
materialSlots |
materialSlotsSchema |
no | — | — | — | materialSlots may not hold more than 64 entries. | — |
metadata |
Record<string, unknown> |
no | — | — | — | — | — |
faceValue |
integer |
no | — | >= -9999, <= 9999 | — | — | — |
overlay |
tableObjectOverlayStateSchema |
no | — | — | — | — | — |
ownerSeat |
string | null |
no | — | <= 40 chars | — | — | — |
parentId |
string | null |
no | — | 1–96 chars | — | — | — |
diceTray |
diceTrayCustodySchema |
no | — | — | — | — | — |
secretMetadata |
secretMetadataSchema |
no | — | — | — | secretMetadata exceeds 2048 bytes when JSON-encoded. | — |
container |
containerStateSchema |
no | — | — | — | 1 further cross-field rule (message built at validation time). | — |
id#
The address, and the only thing that resolves an entity — neither label nor displayName does. Leave it
out and the host mints a UUID as it creates the entity (createObject,
apps/web/src/playcanvas/TabletopRuntime.ts), which is right for most entries: a piece nothing points at
never needs a name you chose.
Write one when something has to reach this entity — another entry's parentId, or a script that looks it
up. Uniqueness is then yours to keep. The runtime indexes objects by id and nothing rejects a repeat, so a
second entry claiming an id takes the address over and strands the first entity with nothing able to name
it. Ids, names and tags has the full three-name split.
kind#
The one field with no default and no way back. kind fixes the collider and the mass, the fallback size
and color, the sound material, and which actions the entity answers — nothing in the intent vocabulary
changes it afterwards, so a different kind is a different entity.
Most of the rest of this schema is scoped by it: stackCount means something only on deck, faceDown
only on card and deck, scale and color are ignored on both. custom is the escape hatch for an
imported model. Object kinds walks all nine and what each is for.
label#
The slug — the machine key, unique in the scene, and the field Edit Mode's Inspector shows as Slug. It is
what a table script reads as ObjectData.name, and for kind: "card" it is also the card's identity:
customDeckCardIdForObject falls back to label when metadata.cardId is absent, and
redactObjectForViewer (packages/shared/src/tableObjects/redaction.ts) overwrites it with Card when a
viewer isn't entitled to see that card. Renaming a card for readability changes which card it is.
The schema checks only the length, so Chess Board parses here. Keep it lowercase, digits and dashes
anyway — that is what the editor writes and what every machine reader expects. displayName is where the
capitals and the spaces belong.
position#
Where the entity starts, in feet, and the only geometry field you must supply — rotation and scale both
fall back to per-kind defaults. The three numbers are world-absolute even when parentId is set:
parenting preserves the world transform rather than reinterpreting them as an offset from the parent, so a
child written at { x: 0, y: 1, z: 0 } is one foot above the table origin, not one foot above its parent.
Spacing is yours to get right. Nothing de-overlaps a setup for you, so two dynamic entries sharing a spot spawn interpenetrating and the solver pushes them apart on the first frame — a starting layout that looks tidy in the file and scatters the moment the table loads.
rotation#
Euler angles in degrees, world-absolute, applied as the entity is created. Absent means
{ x: 0, y: 0, z: 0 }.
y is the yaw, and it is the one you almost always want: it turns a board to face a seat, or fans tokens
around the table without tipping them. x and z tip a piece off its resting face, which for a dynamic
body is a starting pose and not a resting one — a die authored at 30° on x falls flat as soon as the
simulation runs. Use them for furniture you have also marked locked, where nothing is going to knock the
angle back out.
scale#
The entity's local scale. Every built-in shape is a unit box or cylinder, so for those the three numbers are
the piece's full size in feet; on an imported model they multiply the model's own dimensions instead.
Absent means the per-kind default (normalizeObjectScale,
apps/web/src/playcanvas/physics/PhysicsEngine.ts) — a die is 0.05 ft on a side, a board 1.2 × 0.9 ft.
Applies to: die, token, board, bag, custom, card-holder. On card and deck the host derives
the scale from the poker-card footprint and the stack height, at creation and again on every stack change
(syncStackScale), so whatever you write is discarded. A deck gets taller by holding more cards, not by
being scaled.
color#
The base tint of the primitive shape the entity spawns with. The pattern in the table above is matched
case-insensitively — a flag the column cannot show — so #4F7CFF parses as readily as #4f7cff, though
the editor always writes lowercase.
Applies to: die, token, bag, custom, card-holder, and board — except that a board carrying
metadata.boardImageUrl or metadata.boardStyle replaces its material outright and stops using color,
and so does any entity carrying a metadata.materialId that resolves to a project material. On card and
deck it is ignored entirely: those render through the shared card materials, so their faces come from the
deck art and their edges from the card body.
faceDown#
Which way up the entity starts. Absent means false everywhere except kind: "deck", where the host
defaults it to true — a deck you say nothing about arrives backs-up, which is what a draw pile wants and
what a face-up discard pile has to opt out of.
Applies to: card, deck. On any other kind a full definition accepts the field and nothing reads it,
while a template or template reference that sets it is rejected outright. It is the other half of the
hidden-information rule: a face-up card is public to every peer regardless of ownerSeat, and only a
face-down one is redacted on the wire.
locked#
Freezes the entity. The host gives it a static body at creation and the pick, drag and transform paths all
skip it, so nothing a player does moves it. Absent means false.
This is the field for furniture — a board, a card rack, a scoreboard prop — that should stay exactly where
you placed it while the game happens around it. It also propagates upward through parenting: a grab on a
child stops escalating at a locked ancestor instead of dragging the whole assembly by it. The authored
physics.bodyType is left untouched, so unlocking restores the body the entity would otherwise have had.
stackCount#
How many cards a pile starts with, and the number its body height comes from — a deck is stackCount
sheets of 0.2 mm card thick, so a full pack stands about as tall as a real one.
Applies to: deck. This schema carries no cross-field refinement (the template schema does), so a token
or a board with a stackCount parses here and nothing ever reads it.
It is not the field that fills the deck. The host builds the contents from metadata.cards, or from
metadata.customDeck, or — given neither — from a standard 52-card French deck, then overwrites
stackCount with however many entries it built. Ask for 10 and name no cards and you get 52. Name the
cards and let the count follow.
containerMode#
Which end of a container a draw comes off: stack takes the card showing on the pile's face, queue takes
the one at the back, random picks any of them. Applies to: deck and bag, the two kinds that answer
draw and deal. On the other six the field parses and nothing consumes it.
This is the control, and writing it here is enough. resolveContainerConfig
(packages/shared/src/tableContainers.ts) owns the precedence — this field first, then the legacy
metadata.containerMode key, then the per-kind default (random for a bag, stack otherwise) — and both
draw paths reach it through containerDrawModeFor in apps/web/src/playcanvas/TabletopRuntime.ts, so the
host and every peer resolve the same answer. A metadata.containerMode on an older object still works, but
it is legacy: migrateTableSnapshot moves it onto this field and deletes the key on load, so after one save
cycle there is exactly one spelling of the draw order.
capacityLimit#
The ceiling on a container's contents, 1–1000. Applies to: deck and bag.
This is the number the host enforces. containerCapacityFor
(apps/web/src/playcanvas/TabletopRuntime.ts) resolves it through resolveContainerConfig — this field
first, then the legacy metadata.containerCapacity key, then unlimited — and refuses a combine whose
result would exceed it. A legacy metadata.containerCapacity still works but is migrated onto this field and
deleted from metadata on load, so write the field.
The check runs on the merge target, and it is the only capacity check in the runtime: nothing tests it when a card returns to a pile by any other route, so a container can end up holding more than it declares. A refused merge is silent — both piles simply stay as they were.
tags#
Author tags — the cheap grouping mechanism, and what a script's tag / tags filters match against. They
cost no schema and they are many-to-many, which makes them the right way to say "these twelve tokens are
the red army" when id can only say "this one token."
The character class is lowercase letters, digits, _ and -. : sits deliberately outside it, and that
omission is the entire reason the platform's reserved dt: namespace — dt:object, dt:internal,
dt:kind:card — cannot be written from a mod (packages/shared/src/objectTags.ts). Uppercase fails the
parse here rather than being folded down for you, so Red rejects the file where red passes; the
runtime's normalizeObjectTags lowercases and de-duplicates only what reaches it later. See
Tags and groups.
components#
Optional engine components on this entity, and the addable set is exactly two: light and camera.
The intrinsic three — render, collision, rigidbody — are on every entity already and are configured
through the entity's physics state and metadata.materialId, never listed here and never removable.
Scripts are not in this array either: an object script attaches through metadata.scriptId.
At most one entry per type — that is the cross-field rule the table refers to, and a duplicate light
rejects the file rather than being ignored. A camera entry defaults to enabled: false on purpose: the
engine renders through every enabled camera, so switching a second one on puts the whole table inside your
prop. Add component covers the same two from the Inspector side.
displayName#
The human name — free-form, capitals and spaces welcome (Chess Board). It fills the Hierarchy row, the
Inspector's Name field and pc.Entity.name, and when it is absent every one of those falls back to
label (objectDisplayName, packages/shared/src/tableObjects.ts), so an entity is never nameless.
Nothing resolves an entity by it; that is id's job. A mod can read it back off TableObjectState, but a
table script cannot see it at all — ObjectData.name is the label. Set it whenever the slug you need for
machines is not the phrase you want a player to read.
physics#
Per-object physics, overriding the per-kind defaults field by field. Absent fields keep the default, so
{ "collisionShape": "sphere" } changes the collider and nothing else. An absent physics object entirely
means the entity is whatever its kind says it is: see defaultObjectPhysicsForKind
(packages/shared/src/tableObjects.ts) for the per-kind starting point.
The reason this is on the definition and not only on live state is that a spawn has to be able to carry
it. A setup file that places a modelled piece usually needs the collider matching its silhouette, and until
it could be written here the only way to get one was to edit the object after it existed. The built-in
checkers, go stones and Chinese-checkers marble use exactly this: each names the analytic shape its mesh
actually is, instead of taking the box a token would otherwise get.
castShadows#
Whether this entity casts a shadow. Absent means "inherit", not "off".
Three levels resolve it, most specific first:
castShadowson the entity, here — an explicittrueorfalse.- The model's per-asset default:
castShadowsin its.meta.jsonsidecar. That one is authoring config for the whole model, read locally by every peer from the mod tree, and never appears in a snapshot. true. Every entity kind casts by default, so an existing table looks exactly as it did.
Because absent and false are different answers, write the key only when you mean to override the
model. Leaving it out is how an entity keeps following its model's default; setting it to false
opts that one entity out even when the model casts.
Shadow rendering is the single largest per-frame cost on a busy table — the shadow pass redraws every casting mesh from the light's point of view — so this is the field to reach for on entities that cannot visibly benefit: a flat playmat lying on the table, a backdrop, a large low decorative prop. Turning it off on the pieces players actually look at is a visual downgrade for very little, and the runtime already skips the shadow pass entirely while nothing is moving.
Replicated per entity, so changing it reaches every player, survives a save, and can be cleared back to "inherit".
receiveShadows#
Whether shadows are drawn onto this entity. Absent means "inherit", not "off".
The mirror of castShadows and resolved the same way: the value
here, else the model's per-asset default
(receiveShadows in its .meta.json), else true.
The two are independent, and the useful configurations are not symmetric. A table surface or playmat
usually wants castShadows: false with receiving left on — it is what the pieces cast onto, so
losing that is the one shadow players actually read. An entity that glows or is meant to look
self-lit is the opposite case: leave casting alone and set receiveShadows: false so nothing darkens
it.
Turning receiving off is cosmetic, not a performance lever. The cost of the shadow pass is driven by what casts, not by what receives.
Replicated per entity, so changing it reaches every player, survives a save, and can be cleared back to "inherit".
material#
The entity's physical material, and despite the name this is a sound field: it decides which built-in
clip set every interaction resolves to. silent is the sentinel for "make no noise at all". The visual
material is metadata.materialId — different key, different system, easy to reach for by mistake.
Absent means the per-kind default: card for card and deck, plastic for die and token, wood
for board and card-holder, tile for bag, generic for custom. Setting it in a setup file changes
sound only — the matching weight and friction live on physics, so a metal token becomes heavier as well
as louder only if you set both.
See Sound sets.
soundSetOverrides#
Per-action sound replacements for this one entity, keyed by sound action (place, pickup, drop,
slide, shuffle, roll, fall, topple and the rest). Each value is either
{ kind: "builtin", material } — borrow another material's clips for that action — or
{ kind: "mod", modId, name }, naming a set your manifest declares. A raw clip id, path or URL is not
expressible here, and that is a licensing boundary rather than an omission.
Resolution runs override first, then your mod's default mapping, then the built-in set for material or
the kind (resolveSoundRef, apps/web/src/playcanvas/audio/). Reach for an override when one entity
should sound unlike everything else of its material: a bell token that rings, a coin that lands soft.
See Sound sets.
materialSlots#
Per-slot visual material assignment for an entity with a multi-material model: a map from a source
material name to the material to use in its place. The key is the name the material carries inside the
model file — "TimerScreen_mat", "d20_mat" — which is also the name the Edit Mode slot list shows you.
The value is a material id: a builtin: preset or a project material's guid.
{
"kind": "custom",
"label": "timer",
"position": { "x": 0, "y": 0.2, "z": 0 },
"materialSlots": {
"TimerScreen_mat": "builtin:felt",
"TimerCase_mat": "builtin:metal"
}
}
Keyed by name, never by mesh index. Indices are an artifact of how the model was exported: adding a mesh, reordering the export, or re-running an optimizer renumbers them, and an index-keyed assignment then quietly points at a different part of the model with nothing to show for it. The material name survives all of that. If a model genuinely uses one name for two materials they share a slot — the honest trade against silent mis-assignment.
Three levels resolve what a given part of the model renders with, most specific first:
materialSlots["<that part's source material name>"], here — an explicit assignment always wins.metadata.materialId— the all-slots shorthand, unchanged and still fully supported. It is the fallback for every slot with no explicit entry, so a single-material entity needs nothing else and anything already setting it keeps behaving exactly as it did.- The material embedded in the model.
A name that matches nothing in the model is inert, not an error, and a material id that resolves to nothing leaves that slot on its model material — so a stale entry after a re-export degrades to the original look rather than to a missing-texture entity.
At most 64 entries, keys up to 96 characters and ids up to 128. Replicated per entity, so an assignment reaches every player, survives a save and an export, and can be cleared back to the model's own materials.
metadata#
The unvalidated escape hatch, and in practice where a good deal of an entity lives. It replicates and persists like every other field, and nothing checks its shape, so a misspelled key is silent rather than a scan error.
The runtime reads real behavior out of it: cards and customDeck (a deck's contents and its art),
cardId (a card's identity when label is not it), standardPresetId, tokenShape, boardImageUrl,
materialId (the visual material), scriptId (the object script to attach) and grabbableWhileParented.
containerMode, containerCapacity and infinite are legacy container keys: they are still read as a
fallback, but the first-class containerMode / capacityLimit / container fields outrank them and a load
moves them across and deletes them. __redacted is the host's own marker, written onto the wire copy
of an entity a viewer isn't entitled to see — never author it yourself.
faceValue#
The number printed on the face a settled die is showing, exactly as printed. The host computes it when the die comes to rest and replicates it, so every peer reads the same number instead of each client guessing at the geometry itself.
Absent means no value — never null, never 0. A die that is tumbling, held or cocked (leaning on
a piece, wedged against a wall, or resting past half the angle between two of its faces) has no face to
read and the field is dropped rather than left stale; a die imported as a custom model carries no face
table and never gets one, which is why a tooltip on it shows the name and nothing else. There is
deliberately no default. The field is bounded to whole numbers from -9999 to 9999 rather than
assumed positive, so a die that prints 0 or a negative is representable — and a default of 0 would
then make "settled on zero" indistinguishable from "not settled".
Applies to: die. Every other kind accepts the field in a full definition and nothing ever reads or
writes it. The dice shipped as standard presets print 1–sides, including the d10, which is numbered
1–10 here rather than 0–9.
It is on the definition, not only on the state, because the runtime rebuilds an entity field by field from whatever definition it is handed — on first sight of an object, and for every entity on the table after a host migration. A field that lived only on the state would be dropped on exactly those paths, and a die at rest never settles again, so its number would be lost for good.
⚠ Redacted with identity. A die inside a hidden seat zone does not publish what it landed on: the field
is stripped from the wire copy sent to any viewer not entitled to that entity's identity, the same way
label and secretMetadata are. Nothing in the schema itself makes it secret.
overlay#
Live overlay values and counter counts — the only part of the card overlay / counter feature that is ever replicated.
A card overlay has two halves and the split is the whole design. Everything presentational — the region
rectangle a value is drawn into, its font size and colour, a counter's art, anchor and stacking mode, and
the layout template that decided which regions apply to this card at all — lives in the mod's
data/cardSchema.json and is resolved locally by every client from the mod's own repo. None of it is
expressible here. What crosses the wire is numbers:
{ "values": { "pwr": 2, "tou": 2 }, "counters": { "plus-one": 3 } }
values is keyed by the mapped field key your schema already declares in fields[], so a card's live
power really is "the pwr field, plus a delta" and a script reading it needs no translation table.
counters is keyed by counter definition id.
A spawn may carry this, so a mod's setup.json can place a card that starts with counters on it. Carrying
it on the definition is also what makes it survive an entity being rebuilt — first sight of an entity on
a joining peer, an editor Type change, and every entity on the table after host migration.
Changing a value on an entity that already exists is a different operation with a different gate: send a
card-overlay-set intent. The host resolves the region or counter
definition from its own copy of data/cardSchema.json, refuses any key no definition declares, and
checks the per-definition mutation permission (host / controller / seated / script). None of that
depends on your UI having hidden a button.
Per-entry visibility is redaction, not rendering. Each region and counter declares visibility: "hidden" | "count-only" | "full", defaulting to hidden, and the host strips concealed entries from the
snapshot it sends to a peer who is not entitled to that card's identity. Nothing in this bag makes a value
secret by itself; the declaration in the card schema does.
ownerSeat#
Which seat holds this entity. null — the default — means the shared table. The eight seats are named
red, blue, green, yellow, purple, orange, pink and cyan; the schema only checks the length,
so a misspelled seat parses and then matches nobody.
It does two jobs. On a card it makes the body kinematic, which is what keeps a hand card from falling.
And on card and deck it is half the hidden-information rule: a face-down card with an owner is
visible to that seat and that seat's team, while a face-down card without one is host-only. Treat an
authored value as a starting state rather than a binding: the host recomputes ownerSeat from the entity's
position every time a player drops it, so a seat you wrote but did not place inside that seat's zone is
cleared on the first move.
parentId#
The entity this one is attached to; absent or null makes it a root. Parenting is transform and
organization: the child is held kinematic so it cannot fight its parent's body, and position stays
world-absolute because the host preserves the world transform when it attaches. Chains cap at eight deep,
and a parentId naming a missing entity, itself, or anything that closes a cycle is pruned to null on
load rather than trusted.
Grabbing a child moves the whole assembly. That is deliberate — "I glued this token to its base, so moving
it should move the base" — and the per-child opt-out is metadata.grabbableWhileParented. For two pieces
that should both keep simulating while staying connected you want a joint instead. A mod reads this field
back off TableObjectState; a table script cannot see it at all.
See Parenting and Welding.
diceTray#
Marks a die as still belonging to the player who rolled it. Present on a die that is sitting where it landed after a roll; absent on every other entity and on every die a player has picked up and placed.
Three fields, all required when the record is present: seat, peerId and
rollId. You do not author it — the host writes it when it spawns the dice for a roll and
unsets it when a player takes one — but it is in the snapshot, so a script or a save inspector will meet it.
Two behaviours hang off its presence and nothing else. Clearing a roll removes the dice the roller still holds, which is exactly the dice that still carry this field; and a die in custody sits in a dedicated collision group whose mask admits only the table surface, the roll region's walls and other rolling dice, so a roll cannot shove the board around. Picking a die up and placing it unsets the field, which promotes it back to the ordinary dynamic group — a kept die is an ordinary die again, collides with everything, and survives every subsequent clear. That promotion is the feature: "roll, then keep some dice" is why rolled dice are real entities rather than an animation.
Applies to: die. A custody record on any other kind is dropped on load, the same way faceValue is —
nothing writes it there, nothing would ever clear it, and a stray record would leave a permanent entity that a
clear can delete and that other entities pass through. The same normalisation drops a malformed record rather
than failing the whole snapshot, so a hand-edited save cannot wedge a room.
It is public. Dice are not hidden information: whose roll a die came from is already visible from where it is sitting, so this field is not redacted for any viewer. It also cannot carry an identity leak onto a card, because the load-time normalisation deletes it from anything that is not a die.
⚠ If you are writing a tool that edits snapshots, an unset that fails to arrive has two visible faces — a kept die that a later clear deletes, and a kept die that other entities pass through. Clear the field by removing it, never by writing a placeholder record.
secretMetadata#
The property bag that does not reach every player. It is shaped exactly like
metadata — a Record<string, unknown>, any JSON value per key —
and the one difference is the only thing that matters: the host strips it from the snapshot it sends to any
peer who is not entitled to that entity's identity.
The per-kind rule, enforced in redactObjectForViewer (packages/shared/src/tableObjects/redaction.ts):
| Kind | Who receives secretMetadata |
|---|---|
card |
Exactly whoever may see the card's identity — a face-up card, a card in the viewer's own hand, or one revealed to the viewer's team. A face-down card on the table, or in someone else's hand, is stripped. |
deck |
The host only, always. A deck has no per-viewer identity notion at all — its ordered contents are already withheld from everyone — so there is no entitlement to grant one from. |
| every other kind | The host only, always. Same reason: no entitlement exists, so "visible" cannot be justified. |
That table is the wire rule. A mod script is judged against it as a spectator with no seat and no team,
on every peer including the host, so the only secretMetadata a read-world read ever carries is a face-up
card's — which by then is not a secret. To read your own secret back while the card is still face down,
declare read-hidden-information and use
api.getUnredactedSnapshot.
This is the sanctioned home for "which card is this really". label is a card's identity and is rewritten
to Card when hidden; displayName is the human name and is removed when hidden — but both are visible the
moment the card is face-up, and both are the fields the editor puts in front of an author. Put the answer
here instead of encoding it in a name.
Capped at 2 048 bytes of JSON per entity (MAX_SECRET_METADATA_BYTES), measured on the UTF-8 encoding of
JSON.stringify(value). Over the cap, the whole definition fails validation and the spawn is rejected — the
field rides in every snapshot on an entity list capped at 5 000, so it is deliberately an order of magnitude
tighter than the 16 KiB mod saved-data cap. Anything bulky belongs in
saved data, keyed by entity id.
It is a replication boundary, not a cryptographic one. The host's runtime holds the value in full and it is written to the save unredacted, so treat a save file as you would the host's own memory. It protects a secret from the other players at the table; it does not protect one from whoever is hosting. It does withhold the value from a mod running on the host, which has been true since 2026-08-14 and is the assumption most likely to break an existing mod.
container#
The container contract: what sort of container this entity is, whether it ever runs out, what it dispenses,
and what it is holding. Applies to kind: "bag" — the wire kind behind everything the editor calls a
Container, bowls and trays included.
It is one nested object with five keys, each documented in
containerStateSchema:
| Key | What it settles |
|---|---|
form |
holder (open, physical, stores nothing) or bag (closed, virtual, stores its contents). Required. |
infinite |
Never depletes; dispenses copies of source. |
source |
What an infinite container hands out. Absent means it adopts the first piece put in. |
contents |
A finite bag's stored pieces, insertion-ordered and run-length encoded. |
secretContents |
Opt-in hidden information, with a real bandwidth cost. |
It is a first-class replicated field, not metadata, and that is the point. metadata.infinite sat on the
bag-infinite preset for a long time as a label no code read. A real field is validated by Zod at every
boundary — spawn intent, setup.json, save load, import, a mod's createObject — compared field by field for
delta replication, and carried through the snapshot migration. Write it here.
Two rules worth knowing before you author one:
- A
containeron any other kind is dropped on load. Nothing writes it there and nothing would read it, so the loader removes it rather than leaving a key that quietly means nothing. - Every
baggets one. A bag loaded without acontaineris backfilled with{ "form": "bag" }, which is exactly the behaviour a bag had before this field existed. You never have to add it to an old file to keep that file working — you add it to change the behaviour.
Containers do not nest: a stored piece's definition has no container key at all.
What reads it today. The field is validated at every boundary, replicated, migrated and preserved across
save and load, and the container's form and infinite flag already decide which actions its menu offers. The
behaviour the rest of this page describes — putting a piece in, taking one out, and the "looks full" fill —
arrives with the container runtime; author against the contract, and expect a container you write now to be
carried intact until it does.
tableObjectDefinitionSchema.position#
| Field | Type | Required | Default | Min / Max | Pattern | Rule | Description |
|---|---|---|---|---|---|---|---|
x |
number |
yes | — | — | — | — | — |
y |
number |
yes | — | — | — | — | — |
z |
number |
yes | — | — | — | — | — |
position.x
The lateral axis, in feet, measured from the table center. Positive x runs to one side, negative to the
other, and nothing about a piece changes when you mirror it across zero.
The table is not square, so equal offsets on x and z are not equally far out: the default seat ring is
an ellipse about 6 ft along x against 4 ft along z. A row of tokens laid out on x has much more room
before it reaches a seat than the same row laid out on z.
position.y
Height, in feet, as an absolute world Y. Where the tabletop is depends on the table the game assigns:
-0.02 on the default table (close enough to zero to think of the surface as y = 0), 1.5 on the
built-in Dining Table that new games start on, or a table pack's own tablePack.surface.surfaceY.
Measure from that surface.
This is the axis that decides whether a piece rests or drops, and it is a center, not a base. A body rests
when its center is half its own height above the surface: about 0.025 for a 0.05 ft die, about 0.25 for
a 0.5 ft bag. Lower and it starts inside the table and the solver pushes it back out; higher and it falls,
which is what you want for a die and not for a board.
position.z
The remaining horizontal axis, in feet, from the table center — x and z together are the tabletop
plane, and y is up.
z is the short axis of the default seat ring, so it is the coordinate that separates the seats facing
each other: the red seat's zone is centered at x: 0, z: 4 and measures 1.6 by 0.5 ft, with the other
seven spaced around from there. Placing a deck inside a zone does not itself give it an ownerSeat — that
is assigned from position when a player drops something — but it does put a shared pile inside one
player's reach, which is rarely what a starting layout meant.
tableObjectDefinitionSchema.physics#
| Field | Type | Required | Default | Min / Max | Pattern | Rule | Description |
|---|---|---|---|---|---|---|---|
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 | — | — | — | — | — |
collisionSize |
positiveVector3TupleSchema |
no | — | — | — | — | — |
collisionOffset |
vector3TupleSchema <br>same shape as position |
no | — | — | — | — | — |
rigidbodyEnabled |
boolean |
no | — | — | — | — | — |
collisionEnabled |
boolean |
no | — | — | — | — | — |
weldChildren |
boolean |
no | — | — | — | — | — |
physics.bodyType
How the entity participates in simulation. dynamic is moved by forces and collisions, kinematic is
moved only by you but still pushes dynamic bodies, and static never moves at all and is the cheapest of
the three.
Absent means the per-kind default, which is dynamic for everything except board and card-holder. Note
that locked also produces a static body without touching this field, and the two are independent: an
unlocked entity with bodyType: "static" stays put and still responds to picks and transforms. Body type
also constrains the collider — see collisionShape, where mesh is invalid on a dynamic body.
physics.mass
Mass in kilograms; must be greater than zero. Only dynamic bodies have one — the field is accepted and ignored on a static or kinematic entity, because in Ammo a zero-mass body is precisely what "static" means.
Absent means the per-kind default: 0.02 kg for a die, 0.05 for a card, 0.1 for a token, 0.2 for a custom model, 0.3 for a deck or bag. These are real-ish figures and worth keeping so: mass is what decides whether a thrown die scatters a stack of chips or bounces off it, and a piece an order of magnitude out of scale with its neighbours behaves like a bug even though nothing is broken.
physics.friction
Coulomb friction, 0 to 1. Low values slide; high values grip. Absent means the per-kind default, which runs from 0.5 on a die (it needs to tumble) through 0.6 on a card to 0.8 on a board.
This is the field that decides whether a piece stays where it lands on a crowded surface, and it is worth
setting alongside a smooth collider rather than after one misbehaves. A sphere is the clear case: give a
marble a true sphere collider and low friction and it will slide as well as roll, so the built-in
Chinese-checkers marble pairs its sphere with 0.45 here and leans on angularDamping to settle it.
physics.restitution
Bounciness, 0 to 1 — the fraction of speed kept through an impact. 0 is a dead drop, 1 would bounce back to the height it fell from. Absent means the per-kind default: 0.05 for a card or deck, 0.1 for a token, 0.3 for a die, 0 for a board.
Keep it low for anything that should settle quickly. High restitution on a small light piece is the usual cause of a spawn that will not come to rest, because each bounce also re-wakes the neighbours it lands on. Glass and stone sit higher than wood or card — the built-in marble uses 0.25 — but even those stay well under half.
physics.linearDamping
Drag on straight-line motion, 0 to 1: how quickly a moving entity loses speed with nothing touching it. Absent means the per-kind default, 0.05 on a die up to 0.2 on a deck or bag.
Damping is the blunt instrument for a piece that travels too far after a flick. It is not friction — it applies in the air as well as on the cloth — so raising it a long way makes throws feel syrupy. Prefer friction when the problem is sliding across the table, and this when the problem is a piece that keeps going after it should have run out of energy.
physics.angularDamping
Drag on rotation, 0 to 1 — the rotational twin of linearDamping. Absent means the per-kind default, 0.05
on a die (which must be free to tumble or rolls stop looking random) through 0.2 on a token to 0.3 on a
bag.
This field matters most to round pieces, and for a reason that is easy to miss: Ammo's rolling friction is
not exposed through PlayCanvas, so angular damping is the only lever that stops a sphere rolling. A box
collider hides the problem by catching on its own corners; swap in a true sphere and the entity will roll
until it leaves the table unless this comes up with it. The built-in marble uses 0.55.
physics.collisionShape
The collision geometry, which is usually the only field here worth setting. auto keeps whatever the kind
already configured, box, sphere, capsule and cylinder are analytic primitives fitted to the model's
measured bounds, convexHull wraps the mesh in its convex hull, and mesh is the full triangle mesh.
Pick the primitive that IS the shape rather than the most detailed option. For a disc, a cylinder is
exact and costs less than a hull of the same disc; for a sphere, sphere is exact and a hull is strictly
worse; a convex body such as a biconvex go stone is described exactly by convexHull, so nothing is
approximated there either. Reach for mesh only for genuinely concave static geometry — a
btBvhTriangleMeshShape is invalid on a dynamic body, and the host quietly substitutes convexHull when
you ask for one anyway. Both mesh-based shapes need loaded render geometry to build from, so on an entity
whose model is still downloading the change is deferred rather than lost.
Changing this rebuilds the Ammo shape and re-creates the body, which is not free; setting the same value twice is a no-op.
physics.collisionSize
Explicit full dimensions for a box collider, in object-local units — that is, measured as though scale
were 1, because the host multiplies scale back in when it builds the shape. Absent means the collider is
auto-fitted to the model's measured bounds, which is the right answer nearly always.
This exists for imported models whose bounds are not what you want to collide with: one with a wide flat
base, or one whose exported bounding box includes empty space. It applies to box colliders only — the
analytic primitives and the mesh shapes take their size from the geometry and ignore it. Pair it with
collisionOffset when the box also needs to sit somewhere other than the model's centre.
physics.collisionOffset
Where the collision box sits relative to the entity origin, in object-local units. Absent means the auto-fitted centre.
Paired with collisionSize, and like it, box colliders only. The case it solves is an imported model whose
origin is not in the middle of it — a piece authored standing on the world origin, say, whose auto-fitted
box is therefore centred half a model above where it should be.
physics.rigidbodyEnabled
Whether the entity's rigidbody component is switched on. Absent means enabled, so every existing setup file keeps its behaviour.
Disabling freezes the entity without discarding what you authored: mass, friction and the rest stay on the
component and come back when it is re-enabled. That is deliberate, and it is why this is a flag rather than
a removal — render, collision and rigidbody are intrinsic to a table entity and can never be taken
off it.
physics.collisionEnabled
Whether the entity's collision component is switched on. Absent means enabled.
Disabling makes the entity non-colliding while leaving it fully visible — it will pass through everything
and everything will pass through it. Useful for a decorative prop that should not get in the way of play.
As with rigidbodyEnabled this disables rather than removes, so the authored shape survives and returns
intact.
physics.weldChildren
Welds this entity's children into it as a single rigid body, so an assembly collides as one shape instead of as a loose pile of parts that can be knocked out of alignment.
This is a property of the parent, not of the children, and it changes what parenting means: ordinary parenting is transform and organisation only, with a parented child held kinematic so it cannot fight its parent. Welding goes further and merges the collision geometry. Unwelding restores each child's own collider from the shape it had authored.
tableObjectDefinitionSchema.overlay#
| Field | Type | Required | Default | Min / Max | Pattern | Rule | Description |
|---|---|---|---|---|---|---|---|
values |
Record<string, cardOverlayValueSchema> |
no | — | — | — | An object may carry at most 24 overlay values. | — |
counters |
Record<string, number> |
no | — | — | — | An object may carry at most 24 counters. | — |
concealedValues |
integer |
no | — | >= 1, <= 24 | — | — | — |
concealedCounters |
integer |
no | — | >= 1, <= 239976 | — | — | — |
overlay.values
Mutable values, keyed by the mapped field key from your schema's fields[] — not by an id space of its
own. { "pwr": 2 } means "the pwr field, modified".
How the number combines with the card's printed value is a property of the region, not of this bag: a
delta region adds it to the catalogue value (base 2 power + 2 shows as 4), an override region replaces
it. That is why the same stored 2 can render as 4 on one template and 2 on another, and why the
combining rule never has to be replicated.
A value is number or boolean. There is deliberately no string: free-text entry at the table would
need host-side validation against the field's declared type, and every string a region can draw is either
static text from the definition or a catalogue value both sides already resolve locally.
At most 24 entries per entity, each within ±9,999. Both limits are enforced when the intent arrives, so a peer cannot grow the snapshot with them.
overlay.counters
Counter counts, keyed by counter definition id (counters[].id in data/cardSchema.json). At most 24
entries per entity, each 0–9,999 and additionally clamped to that counter's own declared max.
Zero parses — a hand-edited save is not rejected for it — but no mutation ever produces a zero: an entry that reaches zero is removed. "Absent" and "zero" must not both be reachable through play, or the snapshot delta churns between the two encodings forever.
A counter's art, colour, anchor, offset and stacking mode are definition data and resolve locally. So is its
flip lifecycle: on a flip the host applies persist (nothing happens — the default), discharge (the
count is cleared) or transfer (the count moves into another counter). A chain is impossible by
construction — a transferTo target may not itself transfer — so one flip is always one hop.
overlay.concealedValues
Redaction output only. How many overlay values were withheld from this recipient because their region
declared visibility: "count-only".
It is never written by the host's own authoritative state, never persisted, and stripped on load — a saved game is unredacted by design, so a persisted tally would be a lie the next session inherited. If you are reading it on the host, or in a save file, something is wrong.
count-only is the deliberate middle ground between hidden and full: the entry's identity is removed but
its magnitude survives, so a client can draw "this card has something on it" without learning what.
overlay.concealedCounters
Redaction output only — the summed count of every counter withheld from this recipient by a
count-only declaration, so a client can draw N anonymous chips without learning which counters they were.
Same contract as concealedValues: produced on the wire, never by
the host's own state, never persisted, stripped on load.
tableObjectDefinitionSchema.diceTray#
| Field | Type | Required | Default | Min / Max | Pattern | Rule | Description |
|---|---|---|---|---|---|---|---|
seat |
string | null |
yes | — | <= 40 chars | — | — | — |
peerId |
string |
yes | — | 1–96 chars | — | — | — |
rollId |
string |
yes | — | 1–64 chars | — | — | — |
diceTray.seat
The seat the roller was sitting in, or null when they held none. Required inside the record — null is a
value here, not an omission.
It is the label half of custody, and it is what lets a roll be shown as red's roll rather than as a peer id. A
spectating host who rolls produces a record with seat: null; that is an ordinary roll, not a broken one.
Custody is keyed to peerId, not to this. Seats change hands — a player leaves, someone
else sits down — and a clear must still remove the dice of the person who rolled them rather than the dice of
whoever happens to be sitting there now. Read seat for display and grouping; read peerId for ownership.
Capped at 40 characters, matching every other seat identifier in the schema.
diceTray.peerId
The player whose roll this die came from. Required, 1–96 characters, and the identity custody is actually keyed to.
A peer, not a seat, because a seat can change hands mid-session while a roll cannot: clearing a roll must remove the dice of the person who rolled them, and a seatless spectator who rolls still owns their dice. This is the field a clear compares against.
It is a peer id, so it dies with the connection — the same person rejoining is a different peer, and dice left behind by a departed player are no longer matched by any live roller's clear. They are ordinary entities on the table at that point; delete them like any other entity if your game wants them gone.
Not redacted: dice are public, and so is whose roll they came from.
diceTray.rollId
Which roll this die belongs to. Required, 1–64 characters, and shared by every die spawned for the same throw.
It is the same id the roll summary carries, which is what lets a summary panel highlight the dice it is
describing, and what lets a script that stored a
DiceRollSummary find those dice again later: compare this field
rather than searching by position or by time.
A player who rolls twice in quick succession produces two batches with two ids, and both sets of dice stay in their custody until a clear. Grouping dice by roller alone would merge them; this is the field that keeps them apart.
It identifies a throw, not an entity — the die's own id is id. Nothing persists a roll id beyond the session,
so it is not a useful key for saved data across loads.
tableObjectDefinitionSchema.physics.collisionSize#
| Field | Type | Required | Default | Min / Max | Pattern | Rule | Description |
|---|---|---|---|---|---|---|---|
x |
number |
yes | — | > 0 | — | — | — |
y |
number |
yes | — | > 0 | — | — | — |
z |
number |
yes | — | > 0 | — | — | — |
physics.collisionSize.x
Full width of the collision box in object-local units, before scale. Must be greater than zero.
physics.collisionSize.y
Full height of the collision box in object-local units, before scale. Must be greater than zero. This is
the one to check first when an imported model floats above the cloth or sinks into it.
physics.collisionSize.z
Full depth of the collision box in object-local units, before scale. Must be greater than zero.
containerStateSchema#
Exported from @diceytable/shared as containerStateSchema. 12 fields across 4 tables.
| Field | Type | Required | Default | Min / Max | Pattern | Rule | Description |
|---|---|---|---|---|---|---|---|
form |
"holder" | "bag" |
yes | — | — | — | — | — |
infinite |
boolean |
no | — | — | — | — | — |
source |
containerSourceSchema |
no | — | — | — | — | — |
contents |
containerContentRunSchema[] |
no | — | <= 256 items | — | — | — |
secretContents |
boolean |
no | — | — | — | — | — |
Whole-object rules:
- 1 further cross-field rule (message built at validation time).
form#
What sort of container this is — the one required key, and the one that decides whether the container stores anything at all.
form |
What it is | Where its contents live |
|---|---|---|
"holder" |
Open and physical — a bowl, a tray. Pieces fall in and sit there under physics, visible to everyone. | Nowhere. A holder stores no contents data; the pieces inside it are ordinary entities on the table with their own ids. |
"bag" |
Closed and virtual — the contents are not on the table. A piece put into one stops existing as a body and is appended to contents. |
contents, on this field. |
That difference is the whole model, so pick it first. A holder's count is however many entities happen to be
resting inside it right now — the schema refuses a contents array on a holder rather than letting you author
one that nothing would ever read. A bag's count is the sum of its runs.
"bag" is a wire value, not a label: both forms are Containers in the editor and in anything you show a
player.
The field is required because there is no safe default. A container whose form was guessed would either swallow pieces that were meant to rest in it or leave pieces loose that were meant to be stored, and neither mistake announces itself.
infinite#
The container never depletes. Default false.
An infinite container dispenses copies of one authored piece — its source
— and its state does not change when it is drawn from. Fifty draws leave the object's container field
byte-identical, which is exactly the point: nothing to replicate, nothing to run out of, and a supply pile that
cannot quietly be emptied by one player.
It is mutually exclusive with stored contents. The schema refuses infinite: true alongside a non-empty
contents array, because an infinite container has nothing to hold: everything it hands out comes from
source, and a matching piece put back into it is destroyed rather than accumulated.
Both forms can be infinite. An infinite holder is the bottomless bowl — it shows a "looks full" fill that
never shrinks; an infinite bag is the same supply with nothing visible inside it.
This replaces metadata.infinite, which the bag-infinite preset carried for a long time and which no code
ever read. A snapshot loaded from the old key is migrated onto this field, but write the real field in a
hand-authored file: the point of the change is that a flag nothing reads is worse than no flag at all.
source#
What an infinite container dispenses — one piece definition, plus optional provenance for the editor.
source is meaningful only when infinite is true, and the schema
refuses it otherwise rather than letting a finite bag carry a source nothing would consult.
Absent means "not chosen yet", not "nothing". An infinite container with no source adopts the first piece put into it and dispenses that from then on. This is deliberate parity with Tabletop Simulator, and it is what lets a mod ship an empty bowl an author fills by dropping one stone in rather than by hand-writing a definition. Changing or clearing an adopted source afterwards is host-only.
Once a source is set, the container is single-type: a piece matching it is destroyed when it is put back
in, and a piece that does not match is refused and returned to where it was picked up. Matching is by identity
key — see contents.key for what that covers, and note that a colour or
material variant counts as a different piece.
contents#
What a finite bag is holding: an insertion-ordered, run-length encoded list of piece definitions.
Each entry is a run — count copies of one definition — so a bag of 200 identical go stones is one entry of
a few hundred bytes rather than 200 copies of the same object. A piece put in merges into the run in front of
it when it is the same piece (see contents.key); a different piece
starts a new run. Order is preserved because the draw modes depend on it: stack takes from the end, queue
from the start, and random picks a piece uniformly rather than a run — so 199 black stones and one white
one hand out white about once in 200 draws, not half the time.
Finite bags only. The schema rejects a non-empty contents on a holder, whose pieces are loose entities
resting on the table with their own ids, and on an infinite container, which dispenses from source and
stores nothing.
One lane per bag. A bag holds either cards — metadata.cards, the existing deck machinery, with draw,
peek, search and hidden-information redaction already working on it — or pieces, here. Once it holds one
sort it refuses the other. Mixing the two would need a cross-lane order for stack/queue and a second
redaction story, and no game has asked for one.
Three caps bound the field, and hitting any of them is a refusal reported to whoever put the piece in — "this
container is full" — never a silent drop: 256 runs, 10,000 pieces per run, and 48 KiB for the
serialised container field as a whole. The size cap is the one that bites a bag of many distinct pieces;
roughly 250 authored definitions fit.
Contents are not hidden information by default — read
secretContents before assuming they are.
secretContents#
Opt-in hidden information: hide the bag's contents from every peer but the host. Off by default, and the default is the right answer for almost every game.
Why contents are public by default. A piece bag's contents are unseen, not secret — everyone watched each piece go in, and authored starting contents sit in the mod's public GitHub repo where anyone can read them. The thing that must stay secret, which piece the next draw yields, is chosen by the host's private RNG at draw time and is not derivable from the contents at all. So the contents ride the wire in clear, the interface simply does not show them, and the public number is the count. That costs nothing: delta compression stays on and a draw patches one object.
What turning it on costs. For as long as a secret bag is non-empty, the whole table switches to per-peer redacted full snapshots with delta compression disabled — the host re-serialises the entire table once per peer on every broadcast. This is the same scaling cliff a deck of face-down cards already puts a table on, and it is why the flag is per bag rather than the default. On a card table that cost is unavoidable; on a bag of wooden pieces it usually buys no secrecy at all.
Turn it on when pieces are added privately (a blind bid, a hidden draft pool), or when stack/queue order
would tell anyone reading the snapshot what the next draw is. Leave it off otherwise.
A bag holding cards is hidden information already, through the existing card machinery, and needs nothing here.
containerStateSchema.source#
| Field | Type | Required | Default | Min / Max | Pattern | Rule | Description |
|---|---|---|---|---|---|---|---|
ref |
object |
no | — | — | — | — | — |
definition |
containerItemDefinitionSchema |
yes | — | — | — | — | — |
source.ref
Where the source piece came from — provenance for the editor only.
The runtime never resolves it. A draw spawns source.definition and
nothing else; ref exists so the editor can offer "refresh from source" after you edit the preset, model or
template the piece was built from, and so a panel can name what the container dispenses.
That split is on purpose. If the runtime resolved ref at draw time, a container whose model had since been
deleted from the mod would spawn a placeholder box — an asset that silently degrades into a grey cube is worse
than one that refuses the draw and says the source is missing.
Optional, and safe to omit in a hand-written file. A source with no ref still dispenses; you lose only the
editor's ability to re-resolve it.
source.definition
The piece an infinite container dispenses — a full spawn definition minus everything positional.
It is a containerItemDefinitionSchema: what the piece is (kind,
label, its look, metadata, physics, components), with the fields that say where an instance currently
sits removed — no id, position, ownerSeat or parentId — and no container of its own, which is what
makes "containers do not nest" structural rather than a rule to remember.
Every draw spawns a fresh copy of exactly this, and the container's own state does not change. It is also
what an incoming piece is matched against: its identity key (see
contents.key) decides whether a piece put back in is destroyed as a
returning copy or refused as the wrong piece — so a definition that is nearly right, differing by a tint or
a scale, produces a container that rejects the pieces it hands out.
Required whenever source is present. A ref without a definition would leave the runtime resolving an
asset path at draw time, which is precisely what source.ref exists not
to do.
containerStateSchema.contents#
| Field | Type | Required | Default | Min / Max | Pattern | Rule | Description |
|---|---|---|---|---|---|---|---|
key |
string |
yes | — | 1–512 chars | — | — | — |
count |
integer |
yes | — | >= 1, <= 10000 | — | — | — |
definition |
containerItemDefinitionSchema |
yes | — | — | — | — | — |
contents.key
The run's identity key — the answer to "is this the same piece?", cached so the host does not recompute it on every insertion.
It is built from what a player can see and what a draw would reproduce: the kind, the model reference
(metadata.customModelAssetId, else metadata.standardPresetId, else the primitive shape, plus the die type
and side count for a die), the look (color lower-cased, materialSlots key-sorted, metadata.materialId)
and scale rounded to four decimal places. Deliberately not part of it: id, label, displayName,
transform, faceDown, locked, ownerSeat, tags, physics and sound overrides — two pieces differing only
in those are the same piece to a player, and folding them together is what keeps a bowl of stones at one run.
Colour and material variants are different pieces. A blue cube is refused by a bowl of red cubes rather than accepted. The rule errs toward never destroying a piece that differs from what would come back out: if the blue cube were accepted it would be silently recoloured.
Never trusted from a file. Every key is recomputed from its own definition when a save or an incoming
snapshot is loaded, and a wrong one is corrected in place. So a hand-edited save cannot forge a key that makes
the wrong piece match, and you do not need to compute one when authoring — write the definition and let the
platform key it.
contents.count
How many copies of this run's definition the bag holds, from 1 to
10,000.
This is the run-length half of the encoding: 200 stones are count: 200 on one run, not 200 entries. A run
that falls to zero is removed rather than kept as an empty entry, which is why the minimum is 1.
The per-run ceiling is not the bag's ceiling. 256 runs and the 48 KiB size cap bound the field as a whole, and
the container's own capacityLimit, when set, bounds the piece count further. An insertion that would bust
any of them is refused and reported, never truncated.
contents.definition
The piece this run is made of — a full spawn definition minus everything positional.
It is a containerItemDefinitionSchema: the fields that describe what
the piece is (kind, label, its look, metadata, physics, components) with the fields that describe
where it currently is removed — no id, position, ownerSeat or parentId — and no container of its
own, which is what makes "containers do not nest" structural rather than a rule someone has to remember.
Taking a piece out spawns this definition at the container's mouth. Because a run merges pieces that differ only in the fields the identity key ignores, the copy that comes out is this definition and not a record of any particular instance that went in — treat it as the canonical piece.
containerStateSchema.source.ref#
| Field | Type | Required | Default | Min / Max | Pattern | Rule | Description |
|---|---|---|---|---|---|---|---|
type |
"preset" | "model" | "template" |
yes | — | — | — | — | — |
id |
string |
yes | — | 1–160 chars | — | — | — |
source.ref.type
Which catalogue source.ref.id names: a built-in preset, a model
asset in the mod's own file tree, or a template from setup.json.
Editor metadata, like the rest of ref — it changes nothing about what a draw spawns.
source.ref.id
The identifier within source.ref.type's catalogue — a preset id, a
repo-relative model path, or a template id from setup.json.
Editor metadata. It is not resolved at draw time, so an id that has gone stale costs you the "refresh from source" button and nothing else.
containerItemDefinitionSchema#
Exported from @diceytable/shared as containerItemDefinitionSchema. 44 fields across 5 tables.
| Field | Type | Required | Default | Min / Max | Pattern | Rule | Description |
|---|---|---|---|---|---|---|---|
kind |
"card" | "deck" | "die" | "token" | "board" | "bag" | "custom" | "card-holder" | "button" |
yes | — | — | — | — | — |
label |
string |
yes | — | 1–80 chars | — | — | — |
rotation |
vector3TupleSchema |
no | — | — | — | — | — |
scale |
vector3TupleSchema <br>same shape as rotation |
no | — | — | — | — | — |
color |
string |
no | — | — | ^#[0-9a-f]{6}$ |
— | — |
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_-]+$ |
— | — |
components |
objectComponentSchema[] |
no | — | <= 8 items | — | 1 further cross-field rule (message built at validation time). | — |
displayName |
string |
no | — | 1–80 chars | — | — | — |
physics |
objectPhysicsSchema |
no | — | — | — | — | — |
castShadows |
boolean |
no | — | — | — | — | — |
receiveShadows |
boolean |
no | — | — | — | — | — |
material |
soundMaterialSchema |
no | — | — | — | — | — |
soundSetOverrides |
soundSetOverridesSchema |
no | — | — | — | — | — |
materialSlots |
materialSlotsSchema |
no | — | — | — | materialSlots may not hold more than 64 entries. | — |
metadata |
Record<string, unknown> |
no | — | — | — | — | — |
faceValue |
integer |
no | — | >= -9999, <= 9999 | — | — | — |
overlay |
tableObjectOverlayStateSchema |
no | — | — | — | — | — |
kind#
What the stored piece is — the one required field, and the one that cannot be changed later: a different kind
is a different piece. See tableObjectDefinitionSchema.kind for the nine
kinds and Object kinds for what each is for.
It is also the first component of the run's identity key, so a token and a die never share a run even if everything else about them matches.
label#
The stored piece's slug — the machine key a script reads as its name. Same field as
tableObjectDefinitionSchema.label; it is not unique here, because a run
of 200 stones is one definition with one label.
Not part of the identity key. Two pieces differing only in label merge into the same run, and the label
that survives is the one the run was created with — so do not use label to tell stored pieces apart.
rotation#
The resting orientation a drawn copy is spawned with, Euler XYZ in degrees. Same field as
tableObjectDefinitionSchema.rotation.
Not part of the identity key — a piece stored while lying on its side comes back out at this orientation, not at the one it happened to be in. Set it to how the piece should look when it arrives on the table.
scale#
The stored piece's size multiplier. Same field as
tableObjectDefinitionSchema.scale.
Part of the identity key, rounded to four decimal places: a large stone and a small one of the same model are different pieces, kept in separate runs, and an infinite container set up with one refuses the other.
color#
The stored piece's tint, #rrggbb. Same field as
tableObjectDefinitionSchema.color.
Part of the identity key (compared lower-cased). A blue cube is a different piece from a red one — it starts its own run in a finite bag, and an infinite container holding red cubes refuses it rather than absorbing it and quietly turning it red.
faceDown#
Whether a drawn copy arrives face down. Same field as
tableObjectDefinitionSchema.faceDown.
Not part of the identity key — a card put in face down and one put in face up are the same card, which is the answer a player expects.
locked#
Whether a drawn copy arrives locked in place. Same field as
tableObjectDefinitionSchema.locked.
Not part of the identity key. Rarely what you want on a stored piece: a piece that spawns locked cannot be picked up until someone unlocks it.
stackCount#
How many cards a stored deck piece holds. Same field as
tableObjectDefinitionSchema.stackCount, and accepted only on a deck.
Note the one-lane rule before reaching for it: a bag holds either cards or pieces, so a bag whose contents
are pieces refuses a deck, and a bag holding cards keeps them in metadata.cards rather than here.
containerMode#
Which end a draw comes off — stack, queue or random — carried on the stored piece so a drawn deck or
bag keeps its draw order. Same field as
tableObjectDefinitionSchema.containerMode.
Not part of the identity key.
capacityLimit#
The ceiling a drawn deck or bag carries. Same field as
tableObjectDefinitionSchema.capacityLimit. It is the stored piece's
capacity, not the capacity of the container holding it — that one lives on the container's own object.
Not part of the identity key.
tags#
The stored piece's author tags, lowercase and dash-separated, up to 100 of them. Same field as
tableObjectDefinitionSchema.tags.
Not part of the identity key, so pieces differing only in tags merge and the run keeps the first one's tags. Tags are how a script finds a drawn copy afterwards, so put them on the definition rather than expecting a tag added to one instance to survive storage.
components#
Engine components authored on the stored piece — render, rigidbody, collision, light, camera,
script — up to eight. Same field as
tableObjectDefinitionSchema.components.
Not part of the identity key. They are rebuilt on the copy a draw spawns, so a light or a script attached here comes back with every piece taken out.
displayName#
The human-readable name shown for the stored piece — in a search overlay, in a container's menu header, and on
whatever comes out of it. Same field as
tableObjectDefinitionSchema.displayName.
Not part of the identity key, so it merges away like label. Worth setting anyway: it is the name a player
reads when an infinite container refuses a piece ("Only Black Stones go in this container").
physics#
Physics overrides for the stored piece: body type, mass, friction, restitution, damping and the collider. Same
shape as tableObjectDefinitionSchema.physics, and absent means the
per-kind defaults.
Not part of the identity key — two pieces differing only in mass are the same piece to a player, and the run keeps the first one's values.
castShadows#
Whether the drawn copy casts a shadow. Same field as
tableObjectDefinitionSchema.castShadows.
Not part of the identity key. Worth a thought for a piece stored in hundreds: shadow casting is per instance, and a bowl that empties onto the table all at once multiplies whatever you set here.
receiveShadows#
Whether the drawn copy receives shadows. Same field as
tableObjectDefinitionSchema.receiveShadows.
material#
The stored piece's physical surface — wood, cardboard, metal — driving impact sounds and the physics
baseline. Same field as tableObjectDefinitionSchema.material.
Not part of the identity key. Absent means the kind's default, resolved when the copy spawns.
soundSetOverrides#
Per-object sound set overrides carried on the stored piece, so a drawn copy sounds right from its first
contact. Same field as tableObjectDefinitionSchema.soundSetOverrides.
Not part of the identity key: two pieces differing only in their sounds merge into one run, and the surviving run keeps the first one's overrides.
materialSlots#
Per-slot material overrides for the stored piece. Same field as
tableObjectDefinitionSchema.materialSlots; at most 64 entries.
Part of the identity key, compared key-sorted. Two copies of one model finished differently are different
pieces, for the same reason as color.
metadata#
The stored piece's free-form metadata — and the field that carries most of what makes it a particular piece:
the model reference (customModelAssetId, standardPresetId, tokenShape), the die type and side count, and
materialId. Same field as tableObjectDefinitionSchema.metadata.
Those keys are part of the identity key; the rest of metadata is not. So two tokens carrying different
game state under their own keys merge into one run, and whichever state the run was created with is what every
copy taken out of it carries. If a piece's metadata must survive being put into a container and taken back
out, that is a per-instance value, and a container does not preserve per-instance values.
faceValue#
The stored piece's face value — the number a die or token is showing. Same field as
tableObjectDefinitionSchema.faceValue.
Not part of the identity key, and this is the one people expect to be: a d6 stored showing 4 and one stored showing 1 merge into the same run, and what comes out is whatever the run was created with. Dice are rolled after they leave a container, so the stored value is a starting state, not a memory.
overlay#
Overlay values and counters carried on the stored piece — the badges drawn on top of it. Same shape as
tableObjectDefinitionSchema.overlay.
Not part of the identity key. A counter incremented on one instance does not survive storage: the run keeps the definition it was created with, so treat an overlay here as the state a piece starts with.
containerItemDefinitionSchema.rotation#
| Field | Type | Required | Default | Min / Max | Pattern | Rule | Description |
|---|---|---|---|---|---|---|---|
x |
number |
yes | — | — | — | — | — |
y |
number |
yes | — | — | — | — | — |
z |
number |
yes | — | — | — | — | — |
rotation.x
Pitch in degrees, applied to the copy a draw spawns.
rotation.y
Yaw in degrees, applied to the copy a draw spawns. The usual one to set — a piece with a front, such as a printed tile, needs a facing when it arrives.
rotation.z
Roll in degrees, applied to the copy a draw spawns.
containerItemDefinitionSchema.physics#
| Field | Type | Required | Default | Min / Max | Pattern | Rule | Description |
|---|---|---|---|---|---|---|---|
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 | — | — | — | — | — |
collisionSize |
positiveVector3TupleSchema |
no | — | — | — | — | — |
collisionOffset |
vector3TupleSchema <br>same shape as rotation |
no | — | — | — | — | — |
rigidbodyEnabled |
boolean |
no | — | — | — | — | — |
collisionEnabled |
boolean |
no | — | — | — | — | — |
weldChildren |
boolean |
no | — | — | — | — | — |
physics.bodyType
dynamic, kinematic or static for the copy a draw spawns. See
tableObjectDefinitionSchema.physics.bodyType.
Leave it dynamic unless the piece is scenery. A static piece taken out of a container cannot be picked up
or pushed.
physics.mass
The drawn copy's mass in kilograms, greater than zero, and meaningful only on a dynamic body. See
tableObjectDefinitionSchema.physics.mass for the per-kind defaults.
physics.friction
Surface friction for the drawn copy. See
tableObjectDefinitionSchema.physics.friction. Worth setting on a piece
that lives in a bowl: too little and a heap of them slides apart.
physics.restitution
Bounciness for the drawn copy. See
tableObjectDefinitionSchema.physics.restitution. Keep it low for
anything dropped into a container repeatedly — a bouncy piece leaves the bowl it was aimed at.
physics.linearDamping
How quickly the drawn copy loses linear velocity. See
tableObjectDefinitionSchema.physics.linearDamping.
physics.angularDamping
How quickly the drawn copy loses spin. See
tableObjectDefinitionSchema.physics.angularDamping.
physics.collisionShape
The collider the drawn copy is given. See
tableObjectDefinitionSchema.physics.collisionShape.
Not part of the identity key — the collider is not something a player can see, so it never splits a run.
physics.collisionSize
The collider's full dimensions in feet, at the piece's identity scale. See
tableObjectDefinitionSchema.physics.collisionSize.
physics.collisionOffset
The collider's centre offset from the piece's origin, in object-local feet. See
tableObjectDefinitionSchema.physics.collisionOffset.
physics.rigidbodyEnabled
Whether the drawn copy gets a rigidbody at all. See
tableObjectDefinitionSchema.physics.rigidbodyEnabled. A piece taken out
of a container with no rigidbody does not fall, settle or collide.
physics.collisionEnabled
Whether the drawn copy collides. See
tableObjectDefinitionSchema.physics.collisionEnabled.
physics.weldChildren
Whether the drawn copy's parented children are welded into one compound body rather than simulated
separately. See tableObjectDefinitionSchema.physics.weldChildren.
containerItemDefinitionSchema.overlay#
| Field | Type | Required | Default | Min / Max | Pattern | Rule | Description |
|---|---|---|---|---|---|---|---|
values |
Record<string, cardOverlayValueSchema> |
no | — | — | — | An object may carry at most 24 overlay values. | — |
counters |
Record<string, number> |
no | — | — | — | An object may carry at most 24 counters. | — |
concealedValues |
integer |
no | — | >= 1, <= 24 | — | — | — |
concealedCounters |
integer |
no | — | >= 1, <= 239976 | — | — | — |
overlay.values
The overlay's labelled values as stored on the piece. See
tableObjectDefinitionSchema.overlay.values.
overlay.counters
The overlay's counters as stored on the piece — the starting numbers a drawn copy arrives with, not a record
of what any instance reached. See
tableObjectDefinitionSchema.overlay.counters.
overlay.concealedValues
Redaction output only, and it has no business in an authored definition: it counts the overlay values
withheld from one recipient. See
tableObjectDefinitionSchema.overlay.concealedValues.
If you are reading a non-zero value here in a save file or on the host, something is wrong.
overlay.concealedCounters
Redaction output only, like concealedValues — the number of counters withheld from one recipient. See
tableObjectDefinitionSchema.overlay.concealedCounters.
Never authored, never persisted.
containerItemDefinitionSchema.physics.collisionSize#
| Field | Type | Required | Default | Min / Max | Pattern | Rule | Description |
|---|---|---|---|---|---|---|---|
x |
number |
yes | — | > 0 | — | — | — |
y |
number |
yes | — | > 0 | — | — | — |
z |
number |
yes | — | > 0 | — | — | — |
physics.collisionSize.x
Collider width in feet — a full dimension, not a half-extent.
physics.collisionSize.y
Collider height in feet — a full dimension, not a half-extent.
physics.collisionSize.z
Collider depth in feet — a full dimension, not a half-extent.
