Object kinds
TableObjectKind is a Scene Entity's Platform kind
— shown as TYPE in the Inspector's PIECE panel. There are 9 values, defined as plain
string constants in packages/shared/src/tableObjects/kinds/index.ts and collected into
TABLE_OBJECT_KINDS:
card · deck · die · token · board · bag · custom · card-holder · button
The kind is fixed at spawn time (an existing entity's kind is never changed in place) and
drives three things: which physics defaults the runtime applies when the entity has no
physics override, the collision geometry the runtime builds, and which of the
optional TableObjectState fields are meaningful. It does not gate which
actions can target the entity — that is a related
but separate table.
Physics defaults, at a glance#
These are the values apps/web/src/playcanvas/physics/collisionHelpers.ts (createRigidbodyConfig,
called from TabletopRuntime.createObject) reads from
apps/web/src/playcanvas/physics/PhysicsEngine.ts's per-kind functions
(massForKind, frictionForKind, restitutionForKind, linearDampingForKind,
angularDampingForKind) when building an entity's rigidbody and it has no explicit
physics.mass / physics.friction / … override. A kind missing from one of those switch
statements falls through to that function's default case, called out below.
| Kind | Mass | Friction | Restitution | Linear damping | Angular damping | Default sound material |
|---|---|---|---|---|---|---|
card |
0.05 | 0.72 | 0.1 (default) | 0.07 | 0.08 (default) | card |
deck |
1.6 | 0.76 | 0.1 (default) | 0.05 (default) | 0.08 (default) | card |
die |
0.35 | 0.82 | 0.52 | 0.02 | 0.015 | plastic |
token |
0.18 | 0.78 | 0.14 | 0.04 | 0.06 | plastic |
board |
4 | 0.92 | 0.1 (default) | 0.05 (default) | 0.08 (default) | wood |
bag |
0.8 (default) | 0.8 | 0.1 (default) | 0.05 (default) | 0.08 (default) | tile |
custom |
0.8 (default) | 0.82 | 0.1 (default) | 0.05 (default) | 0.08 (default) | generic |
card-holder |
0.8 (default) | 0.8 (default) | 0.1 (default) | 0.05 (default) | 0.08 (default) | wood |
button |
0.8 (default) | 0.95 | 0.04 | 0.12 | 0.3 | plastic |
die's friction (0.82) is deliberately high — it converts a thrown die's slide into
roll/tumble rather than letting it skate flat, and pairs with the highest restitution
(0.52) of any kind so it actually bounces.
A second, unrelated set of "default physics" exists — do not confuse the two.
packages/shared/src/tableObjects.tsalso exportsdefaultObjectPhysicsForKind(), whose numbers do not match the table above (e.g. it givesdecka mass of0.3against the runtime's1.6, anddiea mass of0.02against the runtime's0.35). That function is explicitly documented as "suggestions only" for the editor's physics inspector placeholders (TableEditModeShell.tsx) — it is never written onto object state and never applied by the runtime. The table above, fromPhysicsEngine.ts, is what a spawned object actually gets.
Default scale and color#
When a spawn omits scale / color (and does not come from a
standard preset, which supplies its own), the
runtime falls back to DEFAULT_SCALE / DEFAULT_COLOR in TabletopRuntime.ts:
| Kind | Default scale (ft) | Default color |
|---|---|---|
card |
0.24 × 0.0016 × 0.335 (poker card) | #f6f1df |
deck |
0.24 × (52 × 0.0016) × 0.335 | #a23c3c |
die |
0.05 × 0.05 × 0.05 | #f4f6f8 |
token |
0.12 × 0.04 × 0.12 | #4c8c6a |
board |
1.2 × (board thickness) × 0.9 | #2c6e6a |
bag |
0.5 × 0.5 × 0.5 | #7257a6 |
custom |
0.6 × 0.2 × 0.6 | #60758f |
card-holder |
0.6 × 0.04 × 0.36 | #3a5f7a |
A deck's default scale is the one exception that is not static: absent an explicit
scale, normalizeObjectScale() derives the Y dimension from stackCount (a card
thickness per card, clamped), so a 10-card deck is thinner than a 52-card one even with no
scale authored.
Card#
A single playing card. label is the card's identity for hidden-information
redaction — see IDs, names and tags.
faceDown— whether the card is showing its back.fliptoggles it.ownerSeat— when set, the card is in that seat's hand (getHandObjectsFromSnapshottreats any object with a non-emptyownerSeatas a hand card, regardless of kind).metadata.cardCornerRadius— corner rounding for the rendered card mesh; backfilled to a default if missing or non-numeric.metadata.customDeck— when the card belongs to a custom deck sprite sheet, the sheet slice used to render its face.metadata.faceUrl— a direct image URL for the card's front face (F4): an absolutehttp(s)URL or a repo-relative asset path (resolved the same way any other mod asset is). It textures the whole card face — no sprite sheet, no catalogue — and is the simplest way for a mod script to spawn a card with custom art:createObject({ kind: "card", metadata: { faceUrl: "https://…/anakin.jpg" } }). It renders on the 3D table and in the hand drawer. A face URL identifies the card, so it is redacted exactly likecardId: a face-down card in another player's hand (or an unowned face-down card on the table) never sends itsfaceUrlto other peers — it shows the back. Only face-up (or owner/team-visible) cards expose it.metadata.backUrl— a direct image URL for the card's back. Public (a back is what a hidden card is shown as); omit it to use the standard card back.- Collision: a thin box collider, half-height
max(scaleY / 2, 0.01)— the smallest clearance of any kind (collisionHalfHeightForObject).
Reachable actions: lift, flip, rotate, lock/unlock, combine, flick,
reveal-all/reveal-team-a/reveal-team-b, delete. Not shuffle, draw, deal,
split, roll — see the per-kind action matrix.
Deck#
A container of cards that behaves as one physical stack.
stackCount— how many cards the deck holds; also feeds the default-scale derivation above. 1–1000.faceDown— the deck's face;flipflips every card entry too and re-derives the visible face.metadata.cards— the ordered container entries (cardId+ per-cardfaceDown). Absent on adeck, the runtime synthesizes a standard 52-card deck on first read (buildStandardDeckCardEntries).metadata.customDeck— as forcard, when the deck's faces come from a custom sprite sheet rather than the built-in playing-card art.metadata.faceUrls— a direct-URL face map{ [cardId]: url }(F4), one entry per distinctcardIdinmetadata.cards. The runtime textures the deck's top/bottom card from the matching URL; a card drawn from the deck copies the map (and any sharedmetadata.backUrl) so it stays self-describing. It is the deck-scale sibling of a single card'smetadata.faceUrl. The whole map is host-only. Its keys are the deck's ordered card ids, which no peer may learn (a deck's order is secret), so redaction rebuilds it to at most the single publicly-visible top card's entry — in lockstep with how it strips the orderedmetadata.cardsto the visible top. The one exception is a private peek: a player who runs thepeekaction receives the top-N entries (and their face URLs) un-redacted in their own snapshot only, for as long as the host holds the reveal.metadata.backUrl— a shared direct-URL back for every card the deck holds. Public.splitrequiresstackCount > 1;draw/deal/shuffleoperate onmetadata.cards.
Reachable actions: everything a card can do, plus shuffle, draw, deal, split, peek.
Die#
A single die. label defaults to the generic name "D6" regardless of the die's actual
face count — labelForKind() (apps/web/src/playcanvas/objectHelpers.ts) has one
case for die and does not branch on metadata.dieSides. The face count itself lives in
metadata, not the schema:
metadata.dieSides— 4/6/8/10/12/20 in the standard presets. It is written by the presets (packages/shared/src/standardObjects.ts) and read by nothing: the model and its face numbering are both resolved frommetadata.standardPresetId, not from this. Treat it as informational.metadata.dieFaceStyle— e.g."dots"for a pipped d6.- Collision: a convex hull by default (
basePhysicsForKind's suggestion), so bevels and faces tumble faithfully; the runtime's actual applied physics (table above) uses the generic collider path sincediehas no case incollisionHalfHeightForObject. - Highest restitution and friction of any kind — see the physics table above.
faceValue— a schema field, not metadata: the number printed on the face a settled die is showing. The host computes it on settle and replicates it, and drops it while the die is tumbling, held or cocked. A die imported as a custom model has no face table and never gets one. SeefaceValue.
Reachable actions: lift, rotate, lock/unlock, roll, flick, delete. Not flip,
shuffle, draw, deal, split, combine, or any reveal-*.
Token#
A small marker or piece — checkers, chips, pawns of a generic shape.
metadata.tokenShape—"cylinder"or"box", selecting the rendered primitive.metadata.chipValue— casino-chip presets attach a numeric value here; purely cosmetic/informational, not read by any gameplay path inpackages/shared.metadata.dualSided— e.g. the Reversi chip, which renders two colors on opposite faces.
Printed faces#
A token can carry art on its flat faces — a punchboard counter, a printed chip, a
damage marker — while keeping color as the body.
metadata.tokenFaceImage— art printed on the up-facing side. A repo-relative asset path, a public path (/mods/…), or anhttp(s)/data:/blob:URL.metadata.tokenFaceBackImage— art for the down-facing side. Omit it for a double-sided counter: the face art is used on both sides rather than leaving a blank back.metadata.tokenFaceFit—"cover"crops the art to fill the face; anything else (the default) maps it to the face as authored.
The art is alpha-blended over the body colour, so a PNG/WebP with transparency reads as
an overlay printed on a coloured chip and an opaque image simply hides the colour. Only the
two flat faces are printed — the rim stays color, which is what keeps a picked-up token
looking like a physical counter instead of a cube wrapped in one image. (That wrapping is
what metadata.customTextureAssetId does, and it is the right choice for a flat tile viewed
from above.)
Printed faces require metadata.tokenShape: "box". The face is a rectangular plane
matching the token's footprint, so on a "cylinder" token its corners would overhang the
round body; a cylinder token therefore ignores these keys and keeps its plain colour. Set the
token's scale.x/scale.z to the art's aspect ratio — the face is mapped to the footprint,
so a square image on a non-square token stretches unless you use "cover".
{
"kind": "token",
"label": "Force",
"position": { "x": 0, "y": 0.0, "z": 0 },
"scale": { "x": 0.105, "y": 0.018, "z": 0.105 },
"color": "#b32222",
"metadata": {
"tokenShape": "box",
"tokenFaceImage": "assets/textures/counter-force.webp"
}
}
An unresolved path leaves the token its plain body colour rather than erroring — a repo-relative path whose asset resolver has not primed yet prints as soon as it does.
Reachable actions: lift, rotate, lock/unlock, flick, delete. The narrowest
action set of any kind alongside board and card-holder — no flip, container actions,
roll, combine, or reveal-*.
Board#
A static playing surface — a game board, mat, or other furniture-like piece.
- Nearly every board preset ships
locked: true— boards are meant to stay put once placed. metadata.boardStyle— selects the rendered board art/grid (e.g."chess-wood","go-grid").metadata.boardImageUrl— a board face image, which takes precedence overboardStyle. Accepts a repo-relative asset path (assets/textures/mat.webp), a public path (/mods/…), or anhttp(s)/data:/blob:URL — all resolved the same way every other asset reference is. An unresolved path leaves the board its base colour; it does not fall back to a grid pattern, so a mod's art is never silently replaced by a checkerboard.- Heaviest default mass (4) and highest default friction (0.92) of any kind, consistent with something static and not meant to slide.
- Collision:
collisionHalfHeightForObjectgivesboardits own case — half-height is the scale-derived value plus a configured top clearance, rather than the genericscaleY / 2every other kind gets, so pieces resting on it do not clip through.
Reachable actions: lift, rotate, lock/unlock, delete. flick is the one action
explicitly withheld from board and no other kind
(isObjectActionAllowedForTarget's flick case is target.kind !== "board").
Bag#
A random-draw or ordered container that is not a deck of cards — a dice bag, a chip bank, a Go bowl.
containerMode—"random","stack", or"queue", selecting draw order. The host resolves it from this field first, then the legacymetadata.containerModekey, then the per-kind default (randomfor abag,stackotherwise) —resolveContainerConfig,packages/shared/src/tableContainers.ts. Write the field.capacityLimit— 1–1000, resolved by the same precedence over the legacymetadata.containerCapacity. It refuses acombinethat would overflow the target pile, and that is the only route it gates. The standard presets: 60 (Bowl), 100 (Bag), 200 (each Go Bowl), 1000 (Infinite Bag).container— the replicated container configuration:form("holder"or"bag"),infinite, and the piececontentsof a finite bag. Everybagis given one on load, defaulting to{ form: "bag" }. A legacymetadata.infinite— the "Infinite Bag" preset's flag, which no code once read — is moved ontocontainer.infiniteand deleted frommetadataby the same load.formandinfinitealready decide which container actions a target will accept (isObjectActionAllowedForTarget); the drop-to-absorb and infinite-draw behaviours they describe are still being built, so treat the rest of the field as configuration the platform records and validates rather than something that yet changes play.
Reachable actions: lift, rotate, lock/unlock, draw, deal, flick,
delete. Not flip, split, combine, roll, any reveal-*, or shuffle — a bag
draws at random, so it has no order for a shuffle to change.
Custom#
The escape hatch for anything that is not one of the other eight shapes — chess pieces,
dominoes, mahjong tiles, blocks, notecards, a digital clock, 3D text. Most of a mod's
custom-modeled content is custom.
- Collision defaults to
"auto"in the suggestion function (basePhysicsForKind) — meaning "keep the runtime's current per-kind auto behavior" rather than a fixed primitive; author an explicitphysics.collisionShapefor anything with an unusual silhouette. metadatais where all of the kind-specific behavior lives, because there is no dedicated schema surface for it:tokenShape,text(the "3D Text" preset), and every other preset-specific key in Standard presets.- The "Card Holder" preset (id
card-holder) iskind: "custom", notkind: "card-holder"— see the next section for why that is not a contradiction.
Reachable actions: lift, rotate, lock/unlock, flick, delete. Same narrow set as
token.
Card holder#
A seat- or hand-associated holder entity. Watch the naming: the preset id card-holder
(labelled "Card Holder") is actually kind: "custom", so its name is not a guide to its kind.
The one preset in STANDARD_OBJECT_PRESETS that really is kind: "card-holder" is the KayKit
card-stand ("Card Stand"). So a card-holder entity is usually authored directly (a spawn or
template with kind: "card-holder"), and there is exactly one catalog preset that spawns one.
It also has no dedicated case in any of the runtime's per-kind physics functions or in
collisionHalfHeightForObject — every one of those falls through to its generic default
case for card-holder, which is why the physics table above shows five "(default)" values
for this kind, more than any other.
Holder behavior is driven by metadata, not by the kind. A holder captures a card or deck
dropped within a scale-derived planar radius and stamps metadata.holderId on it; what makes an
entity act as a holder at all is metadata.cardHolder === true, so any kind can be one and
card-holder is simply the stock shape for it.
⚠ A holder no longer confers a seat's ownership by itself. It used to: a holder that had
drifted inside a seat's zone stamped every card it captured with that seat, competing with — and
quietly overriding — the seat's own hand zone. That implicit fallback was removed. What remains
is an explicit opt-in: set metadata.holderOwnerSeat and the holder is treated as that
seat's rack, cards included, exactly as before. With neither, seat membership comes only from
the seat's hand zone. A holder published before that opt-in existed is promoted on load —
its own ownerSeat is copied into metadata.holderOwnerSeat once, so an existing mod's rack
keeps gating who may take cards out of it. The promotion never overwrites a value you wrote,
including an explicit null. See
Player Zones and seat templates
for when to reach for which.
Reachable actions: lift, rotate, lock/unlock, flick, delete — identical to
custom and token.
Button#
A pressable control: a coloured cap sitting in a frame, with optional text or an image on its
top face. Pressing it dips the cap, plays a sound and raises the press action — which a
table or mod script observes as
onPressed.
Two presets ship it: Button (rectangular) and Round Button. They are the SAME kind —
shape is configuration, not a separate type — so everything below applies to both.
Configuration#
Everything about a button lives in metadata.button, validated by buttonConfigSchema
(packages/shared/src/tableButtons.ts). It is metadata rather than object-state fields on
purpose: metadata already travels through the schema, the snapshot delta, migrations and
persistence with no threading, and a button needs a dozen knobs, none of which the server
ever queries.
| Field | Default | What it does |
|---|---|---|
shape |
"rect" |
"rect" or "round". Round uses width as the diameter and ignores depth. |
width / depth / height |
0.34 / 0.24 / 0.075 | The cap's footprint and how tall it stands, in table units (1 unit = 1 ft). |
travel |
0.032 | How far the cap sinks on a press. Clamped to 80% of height at render time. |
pressSeconds |
0.11 | Duration of the dip-and-return. |
colors |
see below | cap, capPressed, frame, base, text. |
frame |
on, 0.028 / 0.026 | The bezel: enabled, thickness, height. |
face |
{ kind: "none" } |
none, { kind: "text", text, size, bold }, or { kind: "image", texturePath, inset }. |
eventName |
"button-press" |
The name a press reports to scripts. |
capPressed is optional: leave it out and the runtime darkens cap by 22% for the held
state, which is usually what you want and always readable.
An image face is a repo-relative path resolved through the same AssetResolver as every
other asset, so it works local-first in the editor and from GitHub for remote peers. A
text face is drawn to a canvas locally and never crosses the wire — the same rule prop
surfaces follow.
Geometry#
The button is built from primitives at runtime, not from a model.glb. That is deliberate:
it is fully described by the numbers you are already editing, so it is correct the instant it
spawns, resizes live as you drag those numbers, and has no asset that can 404 (unlike most
prop presets, which render as placeholder boxes when their model file is absent). The stack,
bottom to top, is plinth → frame → cap → face, with the cap the only part that moves.
The object's scale IS buttonOuterFootprint(config) and every child is a fraction of it,
so there is exactly one place that decides how big a button is.
Pressing#
Click it. The press claims the pointer before the grab path — the same way a prop-surface button does — so pushing a button never also starts dragging it.
A press is an ordinary press object action, which means it is host-authoritative and
replicated like any other mutation: a peer's click sends an intent, the host applies it, and
every client animates its own cap. The cap dip is local cosmetics and starts immediately
on click, because feedback that waits for a round trip feels broken.
A locked button is still pressable. Both presets ship locked because locking is how a
control stays put — a stray drag sliding the End Turn button across the table is never what
anyone meant — so isObjectActionAllowedForTarget exempts press from the locked gate.
Unlock it in the inspector to reposition it.
Reachable actions: press, lift, rotate, lock/unlock, flick, delete.
Sound#
The press sound rides the ordinary sound path: press maps to the settle runtime event,
which OBJECT_SOUND_EVENT_MAP.button binds to a place action against the button's sound
material (plastic by default). To give one button its own click, set a per-object override
in soundSetOverrides — the same mechanism every other kind uses, so there is no
button-specific sound API to learn.
See also#
- Object actions — the full 9-kind × 23-action gating matrix.
- Object state — every
TableObjectStatefield, kind-agnostic. - Standard presets — the 60 ready-made definitions built on these 9 kinds.
- Action vocabularies — the three action vocabularies (engine/table-script/mod), a different axis from this page's per-kind gating.
ObjectKind— the nine kinds the table-script declaration names.- Runtime sound events — the sound events these kinds emit.
SoundMaterial— the materials behind each kind's default sound.- Mod scripting API —
TableObjectState.kind, reading a kind from a mod. ObjectHandle—ObjectData.kind, reading it from a table script.- Types —
SpawnObjectOptions.kind, choosing a kind at spawn.
