A game table is a live world on a server. Everything about it — the objects and where they lie, decks and their cards, zones, drawings, the library of templates, the Global script, every object's script — can be frozen into a table archive: a .zip that the table writes and reads. Autosaves, save slots, downloads, published modules and the file you load from your computer are all this one format.
This page is the reference for that format. Read it if you want to keep a table outside TTCraft, move one between sites, generate one with a tool, or understand why a file you have was refused.
Where archives come from and go
On the room page, the table's menu offers:
- Saves… — named save slots kept on the site. A slot is an archive; loading one replaces what is on the table.
- Download table… — the full archive: the table's state plus every file it uses, ready to load anywhere. On a big table this takes a while; the download starts when it is ready.
- Load from file… — boots the table (or joins it, if it is running) and offers a file picker. The archive is verified on the table server and its objects replace whatever was on the table; the library, effects and gallery are merged in.
- Publish as a module — from inside the table. A module's package is the same archive; other rooms add it to their tables as an addition (fresh object ids, the library merged, the Global script adopted only if the table has none).
The table also autosaves continuously; a closed table reopens from its last state.
The archive
A table archive is a zip with two required entries and two optional ones:
| Entry | Required | Contents |
|---|---|---|
game.json |
yes | The whole state of the table (below). "format" must be "tableweb" and "objects" must be present. |
manifest.json |
yes | The files the state refers to, by hash: {"format": "tableweb-manifest", "version": 1, "blobs": [{"hash", "ext", "bundle", "model", "size"}]}. Its presence marks a current-format archive; an archive without it is refused. |
blobs/<sha256>.<ext> |
no | The bytes of each file the manifest names. Present in a Download table… archive; absent from autosaves, slots and modules, which only name their files because the site already stores them. |
thumbs/t<hex>.png |
no | Cached previews of library items. Derived data; safe to omit. |
The smallest valid archive is therefore two files:
// game.json
{ "format": "tableweb", "objects": [] }
// manifest.json
{ "format": "tableweb-manifest", "version": 1, "blobs": [] }
Loading it gives an empty table of default size. Anything the archive does not mention keeps a default; anything malformed is reported with a reason (not a valid zip archive, archive has no game.json, not a tableweb game export, this archive predates the file store).
game.json
The top level is an object with these keys. All are optional except format and objects.
| Key | What it holds |
|---|---|
name |
The table's display name |
table |
Geometry and look: halfWidth, halfDepth, wallHeight, seats, an optional shape (the outline as loops of [x, z]), surfaceTexture, wallTexture, lighting, surface, and an optional plan — the table plan the table was built from |
objects |
Every object on the table — the records described next. Containers nest their contents inside their own record |
tableMeta |
The table layer: zones (a zone with a seat index is a player's seat; one with a name is a plan area), snaps (snap points), drawings, decals, stamps, fonts, sounds, playlists, sound banks and folders. The coordinate frame and the seat fields are on the Table plan page |
library |
The workbench templates: each with id, name, category, kind, the visual fields, an optional script (Lua source as a string) and the same props an object has |
effects |
Authored visual effects: {id, name, category, spec} |
globalScript |
The Global script — Lua source as a string |
globalData |
The Global script's tw.setTable store |
scriptKv |
The tw.store key/value store |
assets, assetFolders |
The gallery: which files are shown where ({path, kind, name, folder}) |
policy, hands, turns, handSeats, joints |
Table rules: who may spawn and lock, hand settings, the turn system's settings, seat assignments of hand objects, physical joints between objects |
Per-player things — camera, settings, seat colours — are not part of an archive.
An object
Each entry in objects is the same record obj:getJSON() returns in a script and tw.spawnObjectJSON accepts, so the quickest way to learn a kind's shape is to spawn one in the app and print it. The common part:
{
"id": 17, "kind": "die", "guid": "5f0c…",
"pos": [0.12, 0.05, -0.3],
"rot": [0, 0, 0, 1],
"lin": [0, 0, 0], "ang": [0, 0, 0],
"sleeping": true, "locked": false,
"props": { "scale": [1, 1, 1], "mass": 0.02, "friction": 0.9, "restitution": 0,
"collide": true, "lift": true, "liftHeight": 0.05, "rotateOnGrab": false,
"upright": false, "drawable": false, "handable": false, "useGravity": true,
"grab": [0, 0, 0], "defPos": [0, 0, 0], "defRot": [0, 0, 0] },
"visual": { "type": "die", "name": "", "dieType": "d6", "textures": {}, "model": "", "hull": [] },
"script": { "source": "function onLoad() end", "enabled": true, "saved": "",
"name": "", "description": "", "gmNotes": "", "tags": [],
"vars": {}, "tables": {}, "rotationValues": [], "snaps": [] }
}
kindis one of the spawnable kinds (die,deck,card,chip,coin,tablet,counter,clock,note, andpropfor a custom model);tw.getSpawnTypes()lists the ones a script may spawn directly.posis metres with the table surface aty = 0;rotis a quaternion[x, y, z, w].idis the object's runtime id and is kept when an archive is loaded; a module gets fresh ids.guidis the stable identity that scripts, saves and snapshots key on.- Kind-specific fields sit at the top level:
cards(a deck's cards),code(a card),value(a counter),seconds/running/down(a clock),contents(a container's items — full object records, recursively),group(rigid-group membership). visualcarries how the object looks:type,name(its label),textures(a map of slot → path),model,hull(a prop's collider points),modelRotation,back/faces/names(a custom deck),state/states(multi-state objects), atabletdocument, and material scalars.scriptis the object's script state, not just its source:source, whether it isenabled, thesavedstring fromonSave, the per-objectvarsandtables, and the contributions a script declares (snaps,rotationValues). Only the fields you give are read; a bare{"source": "…"}is enough for a new script.
Files and hashes
An archive never points at a URL. Every texture, model, image, sound, font or PDF is referred to by the SHA-256 of its bytes, in one of two path shapes:
library/assets/<sha256>.<ext> a single file
library/assets/b<sha256>/<model.ext> a model bundle (a zip of model + textures); the hash is the zip's
Built-in content uses the app's own paths (models/…, textures/…, assets/…). Any other value — http://…, data:, a //host — is dropped when the archive is read, and the object renders without it. There is no way to make a table fetch something from the web.
manifest.json lists every hash the state uses. When an archive is loaded, the server checks each one against the site's file store:
- known — the store already has those bytes; the archive's copy, if any, is ignored;
- new — the bytes must be in
blobs/; they are hashed as they are read, and stored only if the hash matches the name (imported), otherwise refused (rejected); - absent — named but neither stored nor carried: the object shows as blank (missing);
- blocked — refused by moderation.
The load reports counts for each class; a clean load says nothing. Accepted file types: png jpg jpeg svg webp gif bmp for images, glb gltf fbx obj mtl zip for models, woff2 woff ttf otf for fonts, mp3 ogg wav flac m4a for sounds, and pdf.
Getting assets onto a table
Files reach a table through the table itself: the workbench (upload a model, a texture, an image, a PDF) hashes them, stores them, and puts them in the gallery, from where objects and templates use them. That is the only upload path — there is none from scripts and none on the site outside the table.
To bring a set of files in at once, build an archive: put the bytes under blobs/, name them by hash in manifest.json, reference them from objects or from assets (the gallery) in game.json, and Load from file…. Files the store already holds need no bytes, only their entry in the manifest.
Building an archive by hand
It is possible, and a tool or an AI agent can do it. Three rules make it painless:
- Start from a real export. Build one object of each kind you need in the app, download the table, and use its
game.jsonas the template. The app is the reference for the format, and its records carry every field with a sensible value. - Describe the table as a plan. Put a table plan at
table.plan— the outline as a few primitives, the seat count, the named areas — instead of writing loops and seat zones by hand. An archive that carries only the plan is compiled into all of that when it loads. - Name files by hash, carry them under
blobs/. Compute the SHA-256 of each file, name the entryblobs/<hash>.<ext>, list{"hash", "ext", "bundle": false, "model": "", "size"}in the manifest, and referencelibrary/assets/<hash>.<ext>from the object. For a model bundle, zip the model with its textures, hash the zip, store it asblobs/<hash>.zipwith"bundle": true, "ext": "zip", "model": "<file inside>", and referencelibrary/assets/b<hash>/<model file>.
Scripts go in as source strings: globalScript for the table, objects[].script.source for an object, library[].script for a template. They must be written against the TTCraft scripting API — see below for what that means for saves from other tabletops. The VS Code extension is the comfortable way to edit them once the table is up.
Loading replaces the objects on the table; the rest is merged. Load into an empty table when in doubt.
Saves from other tabletops
TTCraft does not read Tabletop Simulator saves or Workshop mods, nor any other tabletop's format. The reasons are structural, not a missing button:
- Assets by URL. A TTS object names its mesh and textures by URL and the client downloads them. TTCraft names files by content hash and never fetches URLs, so every asset has to be brought in as bytes.
- A different object model. Kinds, physics properties, decks and card faces, containers, states and snap points do not map one to one.
- A different scripting API. The Lua API is deliberately familiar if you have scripted for TTS — many names match — but it is not the same surface, and there is no XML UI: table UI is declared from Lua (Custom UI). A TTS Global script will not run unchanged.
What works is a conversion: download the assets you have the rights to, build the objects — in the app or as an archive by the rules above — and port the scripts against the scripting reference. An archive assembled this way loads like any other.