Deck Editor
Double-click a .deck.json asset and it opens as a tab: a card grid on the left (drop
images to add cards, set copies and data, reorder, label, remove) and settings on the right —
name, per-card resolution, card art (fit, bleed, corner rounding, background), sideways,
back-is-hidden, unique backs, the back-image picker, and the deck's two data models.
<!-- screenshot: editor/item-editors/deck.png -->
See the tab strip conventions — dirty dots, closing, cycling and mounted-but-hidden tabs apply here too.
How a deck is stored#
A deck is not one file. The durable source of truth is the set of per-card images, persisted immediately as you add them:
assets/decks/<slug>.deck.json the deck definition
assets/decks/<slug>/cards/<index>-<stem>.webp per-card images (durable source)
assets/textures/decks/<slug>/sheet-N.webp the generated face sheet(s)
Note where those two image locations sit. The per-card images live under the deck's own
folder, and they are deliberately not declared in manifest.assets: they are your
authoring input, not something the table renders. A player loading your mod downloads the
sheet and never the sources — a 300-card set would otherwise cost every player 300
downloads to render none of them. The sources still publish to your repo and still sync
back when you reopen the deck on another machine; the editor pulls the whole tree, not the
declared-asset list. Keeping them out of assets/textures/ also keeps the Textures library
showing the textures you actually assign to materials.
Decks published before this split kept their sources at assets/textures/decks/<slug>/cards/.
That path is still recognised — reopening such a deck shows its full card list, and its card
images stop being shipped to players the next time the mod is saved or published. New uploads
always take the path above.
Uploading a card scales it down to cover the current per-card resolution with its aspect
ratio intact, WebP-encodes it, and persists it under cards/ right away — that upload is
not undone by closing the tab without saving. The stored image is never squashed to the cell:
how it gets framed is a Card art decision applied at Generate time, which is
what keeps that decision changeable long after the import.
Everything else is saved by one of two buttons in the footer, and the difference is which file has to be rebuilt:
| Button | Writes | Use it for |
|---|---|---|
| Save | .deck.json only |
name, copies, card data, the data models, back image, sideways, back-is-hidden |
| Generate & Save | the face sheet and .deck.json |
adding, removing or reordering cards, changing the per-card resolution, or changing any Card art setting |
The editor tracks which one you need: when an edit invalidates the sheet, Generate & Save is marked with an asterisk and plain Save is disabled. This split matters because Generate & Save has to compose card art; a document-only change like a rename reuses the sheets already on disk, so it saves without touching a pixel — and without being subject to whatever the current card list would re-plan to.
Nothing is playable on the table until you have run Generate & Save at least once.
You no longer have to remember. Save Draft and Publish both flush every open editor tab that owns a backing file first, so a deck you renamed but didn't save is written before the tree is pushed. If a tab can't save, Publish stops and names it rather than shipping the previous file.
Removing a card, and what's in the cards folder#
Remove takes a card out of the deck; the image file stays in the project (delete it
from Textures if you want it gone). The saved .deck.json is authoritative about which
images are cards — a file left in cards/ that the document doesn't list is not
re-adopted, so a removal sticks.
The one exception is a deck whose document lists no usable cards at all — never saved, or every referenced image has since been deleted. Then the editor adopts the card images in the folder, in filename-index order, so an interrupted import isn't lost.
Only decodable image files (.webp, .png, .jpg, .jpeg, .gif, .bmp, .avif)
are ever adopted. That matters because the folder can contain other things: compressing
a card in the Texture editor writes a derived
.ktx2 beside it, and a .ktx2 cannot be decoded into a sheet cell.
Per-card resolution presets#
Three built-in presets set both the card's pixel size and, implicitly, its aspect ratio:
| Preset | Size |
|---|---|
| Poker | 512 × 716 |
| Bridge | 460 × 716 |
| Square | 512 × 512 |
You can also type an exact width/height (32–2048px each). This is the size of a sheet cell — the card's shape and the resolution its art is rendered at.
The chosen size applies to new uploads only: cards already in the deck keep the pixels they were uploaded at, and are re-framed into the new cell at Generate time according to the Card art settings. So changing the preset mid-deck reshapes how existing cards are presented without re-fetching or re-encoding their source images.
Card art#
Four settings control how a source image sits on the card. All four are applied when the sheet is composed, not at import, so you can change any of them at any point and press Generate & Save — the panel is marked sheet-dirty when you do.
The panel leads with a live preview of a real card, drawn through the exact same code that composes the sheet. Click any card in the grid to preview that one.
| Setting | What it does |
|---|---|
| Fit | Cover fills the whole card and crops whatever overflows. Fit inside shows the entire image, letterboxing the remainder with the background colour. |
| Bleed (px) | Pushes the art past the card edge by at least this many cell pixels. |
| Corner rounding | The card's corner radius, as a fraction of its short edge (0 = square, 0.25 = maximum). |
| Background | Fills the letterbox in Fit inside, and the area outside the rounded corners. |
Corner rounding is not a crop — it reshapes the card itself. The runtime generates the card body's mesh from this number, so the rounding applies to the physical silhouette of the deck and of every card drawn out of it, not just to the artwork. Measuring it against the short edge is what keeps a poker card and a square card looking equally rounded at the same value.
Bleed is not an exact per-edge pixel guarantee on a non-square card. Preserving the source aspect means one uniform scale factor, so growing the art enough to clear the short axis by N pixels clears the long axis by proportionally more. Cover guarantees at least the bleed on every edge (exactly that much on the tightest one). The alternative — an exact bleed on all four sides — would require stretching the art non-uniformly, which is the thing these settings exist to avoid.
Decks authored before these settings existed have no art block and keep their previous
appearance exactly: cover, no bleed, square corners, white background.
Copies#
Each card tile has a Copies box. Ten copies of one card cost one image, not ten: a
card's copies share its sheet cell, its label and its data row, so raising the count grows the
deck without growing the sheet or the download. A card with more than one copy shows a ×N
badge on its tile.
This is the right way to build a deck that repeats — a 108-card game of 14 designs is 14 uploads, one sheet, and fourteen numbers. Uploading the same image many times instead costs you a sheet cell per copy, and sheet cells are what turn into video memory.
The footer counts both budgets separately: images (what the sheets have to hold) and cards in play (what the deck spawns with, copies included). Images are capped at eight sheets' worth; cards in play are capped at 1000, and Generate & Save refuses either way rather than trimming silently.
Copies of a card are identical in every respect and spawn grouped together, in card order. Shuffle the deck if you want them interleaved.
Card and deck data#
A deck can carry structured data, declared as a model rather than as free-form JSON. Two lists, both in the sidebar:
- Card data model — the fields every card has. Each field is a key, a type (Text / Number / Yes-No / Choice), an optional display name, and a default value. Once you add a field, a labelled control for it appears on every card tile in the grid.
- Deck data — fields describing the deck itself (a ruleset name, a starting hand size), plus their values. Nothing inherits these; there is one value per field.
Card values are inherited by default. A card that has not been edited shows the model's default in a muted, italic style and stores nothing of its own — which is what keeps a large deck's file small. Type into a cell and that card gains an override, with an undo button next to it to drop the override and go back to inheriting.
Renaming a field's key migrates every value already stored under it. Changing a field's type
converts the stored values in place (a "12" becomes 12; a choice that is no longer offered
becomes empty). Removing a field removes its values everywhere. Keys must be script-safe
identifiers — letters, digits and _, not starting with a digit — and unique within their
list; a row that breaks either rule is flagged inline and skipped when the deck is saved, so a
half-typed key never blocks a save of everything else.
Both models and both sets of values travel with every spawned deck and with every card drawn
out of it, on metadata.customDeck, where a table or mod script reads them. Use
resolveDeckCardData() / resolveDeckData() rather than reading the raw bags — those are the
functions that apply inheritance and coercion. See
customDeckDefinitionSchema for the persisted shape.
Rotating cards#
Real card games mix orientations inside one deck — Star Wars TCG battle and location cards are printed landscape while units are portrait — and a scanned set arrives as one uniformly-sized portrait image per card, with the landscape ones lying on their side. Rotation is how you fix that without re-cutting any art.
Rotating one card#
Every tile in the card grid has a Rotation select: Auto, or an explicit 0°, 90°,
180° or 270° clockwise. Choosing an explicit value pins that card. The grid tile and the
sidebar's Card art preview both turn immediately, and an odd quarter turn also swaps the
preview's width and height — a quarter-turned portrait card is a landscape card, and showing
it any other way would be lying about what the table will render.
Auto is not the same as 0°. Auto leaves the card's rotationQuarter absent so the
deck's rule decides; an explicit 0° pins the card upright against the rule.
Rotating by a card field#
Turning 60 cards one at a time is not authoring. Under Rotate cards by field in the deck
settings, pick one of the deck's card fields and map its values to
rotations — "type = battle → 90°" turns every battle card at once. The editor lists every
value that field actually takes across the deck, plus any the field declares as options, so you
are choosing from real data rather than typing values in.
The rule needs a card field to key off, so the control only appears once the deck has a data model. Per-card pins always win over it.
What rotation does not touch#
The generated sheet. Every cell is still composed at the same orientation, so changing a rotation never invalidates the sheet and never needs a re-generate — turning a card is a document-only edit, exactly like renaming it. It is applied when the card is drawn: the runtime turns the card's UV window onto its own cell and swaps the card body's width and height for odd quarters, so the art is turned rather than squashed.
Only the face turns. Backs are drawn upright (a turned back on a face-down landscape card just looks broken), and so is the reserved hidden-face cell — turning that would leak the orientation of the card behind it, which for an always-landscape card type is a real tell.
The Sideways flag#
The Sideways toggle records that a deck's cards are landscape rather than portrait. It is a
deck-wide setting, saved with the deck and copied onto every spawned deck by
buildCustomDeckObjectMetadata — and no runtime path reads it back. It rotates, resizes and
re-crops nothing.
Treat it as a note to yourself about the deck's intent. If you want the table to actually change, use per-card rotation, which is the mechanism that does what this flag only ever described. To ship a deck that is landscape in its cells, author landscape cells (a card width greater than its height) and scale the placed Entity to match.
See Known limitations.
Authoring a deck is not the same as placing it#
⚠ Saving a deck does not put it on the table. It writes .deck.json; a separate
step drags that asset onto the viewport, and only that step bakes the deck into a Scene
Entity. You can edit and republish a deck indefinitely while the table still holds a
built-in deck preset — the two are indistinguishable on the table and in the
Hierarchy, because the only difference is a metadata.customDeck slice you cannot see.
Two things now make the difference visible:
- The Deck source row in the inspector's PIECE section names it —
Custom: <deck id>with the image/card counts, or a warning-coloured Standard 52-card deck with a note on how to replace it. - The Publish dialog warns when a deck asset in the project isn't placed in the scene. It is a warning, not a gate — a deck that only a script spawns is legitimate.
The back picker#
Back image defaults to a bundled generic card back. Pick any image asset already in your project instead — the dropdown lists every visible project image. v1 supports exactly one shared back for the whole deck; Unique backs is visibly present but disabled, reserved for a later phase where each card can carry its own back image.
Back is hidden controls what a hand-hidden card shows in place of its face: by default, hand-hidden cards reveal the sheet's last card image (the Tabletop Simulator convention); turning this on shows the deck's back image instead. This only affects what a peer sees when a card is held face-down in a hand zone — it has no effect on a card lying face-up or face-down loose on the table.
Sheet generation#
⚠ The sheet layout is row-major with a V-flip — this is not optional, and getting it wrong is how a hand-authored or hand-inspected sheet ends up looking scrambled.
- Cards are packed into the sheet row-major, top-left first: card index 0 in the top row's leftmost cell, index 1 to its right, wrapping to the next row down once a row fills.
- The sheet image itself is composed with row 0 at the top, the ordinary way a canvas draws an image.
- The UV coordinates the runtime samples are V-flipped relative to that image,
because texture-space V=0 is the bottom of a texture, not the top.
cardSheetUvWindow()(the function every consumer uses to compute a card's UV window) accounts for this by computing each card's V-offset as1 - (row + 1) × cellVrather thanrow × cellV.
You never have to do this arithmetic by hand — Generate & Save and the runtime's UV
sampling already agree on it — but if you ever hand-edit a .deck.json or write your own
sheet compositor, packing row-major and forgetting the V-flip is exactly how a deck's
cards render in the wrong cells or upside-down relative to what the sheet image shows.
Generation also draws a small bleed guard (1px gutter, matching the runtime's
CUSTOM_DECK_SHEET_GUTTER_PX): each card is drawn once across its full cell and once
again inset by the gutter, so texture filtering at a card's edge samples its own bled
border instead of a neighboring card. This gutter is a rendering detail and is not the
same number as the authorable Card art bleed — the gutter guards texture
filtering, the art bleed decides framing.
The generator packs one cell per distinct card image — copies do not consume cells — into
as many 4096px sheets as the deck needs. 62 poker-sized cards at 512x758 already need two;
they are written as sheet-1.webp, sheet-2.webp and so on beside each other, and a card's
faceIndex runs straight through the whole set rather than restarting per sheet. Sheets the
deck stops needing are deleted on the next Generate & Save, so a deck that shrinks does not
keep shipping card art nothing renders.
The footer shows a live layout summary: the grid of each sheet, the deck's size in play when copies are in use, and — once there is more than one sheet — the video memory the sheets add up to. That last number is the one to act on. Each 4096px sheet decodes to about 64 MB, the pack budget warns at 96 MB and refuses at 256 MB, and halving the per-card resolution quarters the memory while quadrupling the cards per sheet. Generate & Save refuses only past eight sheets, and says so rather than dropping cards.
The performance budget in the footer#
Next to the layout summary the footer carries a live performance budget pill — the same classifier that decides whether a publish is refused, running on the deck you have open, so a refusal is never the first time you see the number. Hover it for the full per-field breakdown.
Three things it reports are worth understanding, because two of them are commonly misunderstood:
- Largest sheet side is the only field that can refuse a publish. A sprite sheet larger than
the GPU's texture ceiling never uploads and every card in the deck renders with no art at all —
measured, a 32,768px texture raised a hard driver error on a high-end desktop GPU, and 8,192px
costs 256 MB of video memory for one texture. Generate & Save caps itself at 4,096px, so a
deck you built here cannot trip it; a hand-written or imported
.deck.jsoncan. - Deck cards counts physical cards, copies included, and it warns but never refuses. This is the dealt cost, not the shipped one: a 300-card deck measured 6 draw calls sitting on the table and 2,448 once the same cards were dealt out. How much of a deck gets dealt is the game's choice, so a big deck is never blocked.
- Distinct card faces is the number that actually moves frame time, because each distinct face becomes its own material. ⚠ A sprite sheet does not avoid this. A card's UV window is stored on its material, so sheet-based cards each need one anyway — measured, 300 cards cost the same 2,784 draw calls whether they came from one sheet or 300 separate images. What a sheet buys is one HTTP fetch instead of 300 and one texture object instead of 300, which is a load-time win, not a frame-time one.
See also#
- Item editors: Texture — resizing or re-encoding a card image after upload
- Building a deck
- Table Object & Type Reference — the
deckkind's runtime behavior ObjectHandle—shuffle, reordering a finished deck from a table script.
