Dicey Table

Manifest reference

Every mod has exactly one manifest, a JSON file validated by modManifestSchema (packages/shared/src/modManifest.ts). This page documents every field: what it does, who reads it, its constraints, the exact validation message you'll see when it's wrong, and a worked example.

Verified field count (2026-09-19): 31 top-level fields. The generated field tables below (schemaSymbols.json) are the authoritative list and are what the field-by-field breakdown is built from — count from there rather than from this page's prose, which has twice fallen behind (an earlier hand count of 20 top-level / 32 leaf fields predates assetRepos, plugins, dependencies and setupOptions, the "25" it replaced predates lobbyRequirements, and the "26" after that predates rules, dice, diceEnabled, credits and excludedAssets).

The filename — and a footgun#

The canonical manifest filename is diceytable.mod.json, at the repo root. gametable.mod.json is accepted as a legacy fallback from the pre-rebrand "GameTable" naming.

The fallback logic (apps/server/src/githubScanner.ts, fetchManifestText) tries diceytable.mod.json first and only reads gametable.mod.json if that fetch throws for any reason — a real 404 because the file doesn't exist, but also a GitHub 500, a network blip, or any other transient failure:

async function fetchManifestText(owner, repo, ref) {
  try {
    return await fetchText(rawUrl(owner, repo, ref, "diceytable.mod.json"));
  } catch {
    return await fetchText(rawUrl(owner, repo, ref, "gametable.mod.json"));
  }
}

This is a footgun worth naming explicitly: if diceytable.mod.json exists but GitHub has a transient outage while the scanner is reading it, the scan silently falls through to gametable.mod.json — which may be stale, absent, or (if you keep both in sync, as recommended below) byte-identical and harmless. If you only maintain diceytable.mod.json, a transient failure produces a hard scan error instead of a silent stale-content bug, which is easier to diagnose. Our own reference mod (mods/example) ships both files, kept byte-identical, verified 2026-07-27 with a byte-for-byte diff — that's the safest pattern if you want insurance against the fallback ever firing for the wrong reason.

Required vs. optional, at a glance#

Field Required? Default
schemaVersion required
id required
name required
version required
type required
entry required (object; both members optional)
compatibility required
license required
slug optional
assets defaulted []
assetManifest optional
excludedAssets optional
capabilities defaulted { version: "1", allowed: ["log"] }
tags defaulted []
summary optional
description optional
category optional
players optional
coverImage optional
screenshots optional
rules optional
dice optional
diceEnabled optional
soundSets optional
assetRepos optional
pluginSettings optional
plugins optional
dependencies optional
setupOptions optional
lobbyRequirements optional
credits optional

Field-by-field#

schemaVersion#

  • Type / constraint: literal "1.0" — the only accepted value.
  • Who reads it: the manifest parser (modManifestSchema) itself, before anything else.
  • Validation: fails schema validation (generic Zod literal mismatch) if not exactly "1.0".
  • Example: "schemaVersion": "1.0"

id#

  • Type / constraint: string, 3–96 chars, pattern ^[a-z0-9][a-z0-9._-]*[a-z0-9]$ (case-insensitive) — starts and ends alphanumeric, interior may contain dots, underscores, dashes.
  • Who reads it: the registry (primary key for a registered mod), the scanner, discovery, and every packRefs pin, collection entry and room selectedModIds that names this pack.
  • Where it is edited: Mod ▸ Mod Details… ▸ Details ▸ Mod ID. Editable until the pack is published, then locked — an id is what other people's documents pin, so changing a published one mints a second mod rather than renaming the first.
  • Validation: generic Zod regex/length failure; no custom message is set on this field. Edit Mode states the rule in place and refuses the publish before the push, so a bad id is never discovered as a scanner refusal on the far side of a GitHub commit.
  • Example: "id": "local.example.starter-pack"

slug#

  • Type / constraint: optional string, 1–60 chars, pattern ^[a-z0-9]+(?:-[a-z0-9]+)*$ (lowercase letters/digits, single interior dashes — stricter than id: no dots, no underscores, no leading/trailing/double dashes).
  • Who reads it: the public /games/<slug> (and /mods/<slug>) route. A mod with no slug routes on its id instead — but Edit Mode no longer leaves it absent (see below).
  • Validation message: "Use lowercase letters, numbers, and single dashes."
  • Where it comes from: Mod Details sets it from your title the first time you edit anything there, so a game called "Chess" is served at /games/chess rather than at whatever its project id happens to be. Editing the Game URL field overrides that; Reset re-derives it from the current title.
  • It does not follow a rename. Once stored, the slug stays put when you retitle the game, because a published URL is something other people have linked to and a rename must not silently move it. Press Reset if you do want it to catch up.
  • Both keys keep working. A pack resolves from its id and its slug, so adding a slug never breaks an existing link — the id form permanently redirects (301) to the slug form, which is what the page's canonical names. The same redirect corrects the prefix: a game reached at /mods/<slug> goes to /games/<slug>, and any other pack type reached at /games/<slug> goes to /mods/<slug>.
  • Consequence: once a mod has real players, changing slug changes its public URL — treat it as a stable identifier, not a display label.
  • Example: "slug": "starter-table-pack"

name#

  • Type / constraint: string, 1–80 chars.
  • Who reads it: discovery UI, the mod list, the details page title.
  • Example: "name": "Starter Table Pack"

version#

  • Type / constraint: string matching ^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$ (semver, optional pre-release/build metadata).
  • Who reads it: publish history, discovery. This is a separate string from compatibility.engine, and unrelated to itversion is the mod's own release number; compatibility.engine targets the DiceyTable engine (see Engine compatibility).
  • Validation message: "Use semver, for example 1.0.0"
  • Example: "version": "1.0.0"

type#

  • Type / constraint: enum "game-pack" | "component-pack" | "table-pack" | "room-pack".
  • Who reads it: almost everything. It decides which payload schema your entry.setup is parsed with, which editor the project opens in, which browse taxonomy your category comes from, which listing your pack appears in (/games/ is game packs only; everything else lives under /mods/) — and which manifest fields you may declare at all, per the table in the next section.
  • ⚠ It is immutable after your first publish. The server refuses a changed type with mod-type-immutable (409): the kind is part of what other packs depend on, so it is a one-way door rather than a preference.
  • Disambiguation: component-pack describes this mod's shape and has nothing to do with the server-side "package" concept (packageLifecycle.ts). Its author-facing name is asset pack — "component" means an engine component everywhere else.
  • Example: "type": "game-pack"

Which fields your pack type can use#

The manifest began life as a game manifest and was then handed to three other kinds of pack unchanged, which is how a room could declare the players who sit at it. It no longer can. packages/shared/src/modTypeFields.ts is the one table every layer reads — the schema, the publish scanner and the manifest editor — so the form cannot offer a control that writes a refusal.

Everything not listed here (id, name, version, entry.setup, assets, excludedAssets, assetRepos, compatibility, license, tags, summary, description, category, coverImage, screenshots, soundSets, slug, dependencies) means the same thing for all four kinds.

Field Game Table Room Asset pack Refused by
entry.script the schema, at parse
capabilities the schema, at parse
plugins the schema, at parse
pluginSettings the schema, at parse
setupOptions the scanner, at publish
lobbyRequirements the scanner, at publish
players ✅ (as Seats) nobody — the editor simply does not offer it

Why three different enforcers. A field is refused at parse only when it contradicts the type — a room or table pack runs no script, so plugins describes a reach it cannot have, and no manifest in the wild could legitimately be carrying one. A field that is merely inert is refused at publish instead, where you are present to be told: refusing it at parse would take a pack that is already published and merely carrying a dead key off every table that depends on it. A field that is only wrong as prose — a browse card printing a player count for a room — is refused by nobody; the editor stops offering it, and lists it with a Remove button if a hand-written manifest has one.

Being the room's game is not the same as carrying a script. setupOptions and lobbyRequirements are both read from the one mod the lobby resolves as the game (the room's provider, or its single selected mod). A game pack with no entry.script may still declare lobby requirements — the platform asks and applies them — and an asset pack full of scripts may not, because it is a dependency of a game rather than the game.

entry#

  • Type / constraint: object { setup?: string (1–180 chars), script?: string (1–180 chars) }. Both members are optional — entry: {} is valid.
  • Who reads it: the scanner (fetches and validates whichever is present), the play-mode artifact fetch (fetchGitHubModArtifact, same conditional fetch).
  • Why both are optional: an asset-only component-pack — a shared model library, a card-back pack — legitimately has no entry point; there's nothing to set up and nothing to script.
  • ⚠ The gap this creates: scanGitHubMod skips whichever branch is absent and falls through to status: "compatible". An empty entry publishes successfully and does nothing at the table. If your mod scans compatible but spawns no objects, check entry first — an empty or missing setup means there was never anything to place.
  • entry.script constraint: if present, must end in .js (validated separately — see unsupported-script-type below).
  • Example (full):
    "entry": { "setup": "setup.json", "script": "scripts/main.js" }
    
  • Example (asset-only mod — valid, does nothing at the table):
    "entry": {}
    

assets#

  • Type / constraint: array of strings (1–180 chars each), max 2000 (MOD_MAX_DECLARED_ASSETS), defaults to [].
  • Who reads it: the scanner (fetches, hashes and content-type-checks every one — see Assets); treePathsForManifest() includes every entry in the canonical repo-path set.
  • Who writes it: you do not maintain this list by hand. It is a derived inventory: the editor recomputes it from the project's file tree on every add, delete and move (and again on Save Draft), and the publish endpoint recomputes it from the pushed tree. The manifest's own file, entry.setup, entry.script, rulebook pages under rules/, and editor-only files (originals/, thumbnails/, screenshots/, a deck's per-card source images, link records, and any extension outside the allowlist) are always excluded — modAssetUndeclarableReason() in packages/shared/src/modManifest.ts is the one spelling of that rule. If you hand-edit the array, the next save overwrites it.
  • The one thing you can change: keeping a declarable file out, with excludedAssets. The manifest editor's Files tab shows every file's state and is where you set it.
  • Constraint — extension allowlist: every path's extension must be one of the 19 in MOD_ASSET_ALLOWED_EXTENSIONS (see Assets for the full list).
  • Validation message (bad path): "Mod paths must be relative repository paths without traversal or external URLs." (code unsafe-path)
  • Validation message (bad extension): `Unsupported asset type: ${extension || "none"}.` (code unsupported-asset-type)
  • Example: "assets": ["assets/board.png", "assets/models/die.glb"]

assetManifest#

  • Type / constraint: optional array of { path: string (1–180), sha256?: string (64 hex chars) }, max 2000.
  • Who reads it: the scanner and the play-mode pull-cache, as an optional per-asset catalog (repo path + content hash) that lets them verify/dedupe assets without issuing a HEAD request per file. Absent on older manifests — nothing requires it.
  • Example:
    "assetManifest": [
      { "path": "assets/board.png", "sha256": "3f9a…64 hex chars…c1" }
    ]
    

excludedAssets#

  • Type / constraint: optional array of strings (1–180 chars each), max 2000. Absent when empty — the editor removes the key rather than writing [].
  • What it is: declarable files you have deliberately kept out of assets. Because assets is recomputed from the file tree on every save and at publish, an exclusion cannot be expressed by deleting a line from assets — the next reconcile would put it back. It is stored here instead.
  • Who reads it: reconcileManifestFilePaths() (the editor, on every save) and the publish endpoint's asset inventory. Both leave the listed paths out of assets. Nothing at the table reads it.
  • What an excluded file does: it is still published to your repository and still loads — on demand, the first time something uses it, unverified and without the registration-time size check and hash. It is not part of the download players make before the table opens.
  • Who writes it: the Pre-load checkbox beside each game file on the manifest editor's Files tab. The reconciler keeps it honest: it prunes an entry whose file is gone, follows a rename, drops an entry that could never be declared (an entry file, a thumbnail, an original), and removes the key when the list empties.
  • Pack types: all four. It is not a type-restricted field.
  • Older servers: a server built before this field strips the key and re-declares the files. The exclusion is ignored; the manifest is never refused for carrying it.
  • Example: "excludedAssets": ["assets/expansion/board-xl.png"]

compatibility#

  • Type / constraint: object { engine: string, 1–40 chars }.
  • Who reads it: the scanner, via isCompatibleEngineRange(manifest.compatibility.engine, ENGINE_VERSION) — the current engine version is "0.1.0".
  • Validation message (incompatible): `Mod targets ${manifest.compatibility.engine}; current engine is ${ENGINE_VERSION}.` (code engine-incompatible)
  • ⚠ Not semver. The grammar is a hand-rolled 2-token parser, not a real range checker — see Engine compatibility for the full, exact grammar (including a form that silently matches everything).
  • Example: "compatibility": { "engine": ">=0.1 <1.0" }

capabilities#

  • Type / constraint: object { version: literal "1" (default "1"), allowed: ModCapability[] (max 20, default ["log"]) }. The 12 capability slugs are documented on Mod capabilities.
  • Who reads it: validateManifestCapabilities() at scan time — cross-checks against capabilities the scanner detects your script using (via 12 regex detectors over entry.script's text). Declaring a capability you don't use is fine; using one you haven't declared is a scan error.
  • Validation message: `Script uses capability "${capability}" but it is not declared in manifest.capabilities.allowed.` (code undeclared-capability)
  • The elevated one: read-hidden-information gates api.getUnredactedSnapshot() alone. It is never implied by read-world — whose six reads are redacted to the least-privileged view on every peer, the host included — and never granted by default. Declaring it tells a reviewer and a player that this mod can see hidden cards.
  • Example: "capabilities": { "version": "1", "allowed": ["log", "spawn-object", "read-world"] }

license#

  • Type / constraint: string, 1–80 chars. Free text — there is no SPDX validation, no enum, no allowlist. "license": "whatever I want" passes as readily as "license": "MIT".
  • Who reads it: discovery UI, displayed as-is on the mod's details page. Nothing parses or enforces it.
  • Example: "license": "MIT"

tags#

  • Type / constraint: array of strings (1–32 chars, pattern ^[a-z0-9-]+$ case-insensitive), max 20, defaults to [].
  • Who reads it: discovery/search filtering. Distinct from category (single, curated) — tags are free-form and multi-valued.
  • Example: "tags": ["cards", "starter", "sandbox"]

summary#

  • Type / constraint: optional string, trimmed, max 200 chars. Plain text — the UI enforces no formatting.
  • Who reads it: mod cards/lists on discovery pages.
  • Example: "summary": "A quick-start pack of cards and dice for prototyping games."

description#

  • Type / constraint: optional string, max 20,000 chars. Markdown (authored via a WYSIWYG editor that emits markdown).
  • Who reads it: the mod's details page (/games/<slug>).
  • Example: "description": "# About this pack\n\nA general-purpose starter set..."

category#

  • Type / constraint: optional, must be one of the 66 curated slugs — see Categories.
  • Who reads it: discovery's browse-by-category filter.
  • Validation: generic Zod enum mismatch if the slug isn't in the curated list.
  • Example: "category": "worker-placement"

players#

  • Type / constraint: optional object { min: int 1–64, max: int 1–64 }, with a refine requiring max >= min.
  • Who reads it: discovery cards/filters (player-count range).
  • Validation message: "players.max must be greater than or equal to players.min."
  • Example: "players": { "min": 2, "max": 4 }

coverImage#

  • Type / constraint: optional string, 1–180 chars — a repo path under media/.
  • Who reads it: discovery cards, the details page hero image. Scanned as an image (see Cover art and screenshots) even though it isn't listed in assets.
  • Validation: must be a safe relative path under media/, or `coverImage and screenshots must be relative paths under the media/ folder.` (code media-path-invalid).
  • Example: "coverImage": "media/cover.webp"

screenshots#

  • Type / constraint: optional array of { path: string 1–180, crop?: CropRect }, max 12. CropRect is { x, y, w, h }, each a fraction 0–1 of the source image (w/h strictly > 0), with a refine requiring the rectangle to stay inside the image.
  • Who reads it: the details page gallery. crop lets the editor re-open and re-crop the original source image rather than re-uploading.
  • Validation message (crop out of bounds): "Crop rectangle must stay within the image bounds (x+w and y+h ≤ 1)." — note this checks both x + w ≤ 1 and y + h ≤ 1 (with a small floating-point epsilon of 0.0001), not just the x-axis.
  • Example:
    "screenshots": [
      { "path": "media/screenshot-1.webp", "crop": { "x": 0, "y": 0.1, "w": 1, "h": 0.8 } }
    ]
    

soundSets#

  • Type / constraint: optional array of { name: string (pattern ^[a-z0-9][a-z0-9._-]*$, 1–80), variants: string[] (1–16 repo-relative asset paths), material?: SoundMaterial, action?: SoundAction, loop?: boolean }, max 64.
  • Who reads it: api.playSound({ modSound }) / api.setObjectSound(...) in mod scripts — mods declare and play their own uploaded clips this way; they can never reference the first-party licensed sound library by id (a deliberate licensing constraint, not a technical accident).
  • Example:
    "soundSets": [
      { "name": "dice-clatter", "variants": ["assets/sfx/clatter-1.wav", "assets/sfx/clatter-2.wav"], "material": "wood", "action": "roll" }
    ]
    

A complete, minimal, real manifest#

Verified byte-for-byte against mods/example/diceytable.mod.json (2026-07-27):

{
  "schemaVersion": "1.0",
  "id": "local.example.starter-pack",
  "name": "Starter Table Pack",
  "version": "1.0.0",
  "type": "game-pack",
  "entry": {
    "setup": "setup.json",
    "script": "scripts/main.js"
  },
  "assets": ["setup.json"],
  "compatibility": {
    "engine": ">=0.1 <1.0"
  },
  "capabilities": {
    "version": "1",
    "allowed": ["log", "spawn-object", "register-action", "read-world", "object-action", "saved-data", "subscribe-events"]
  },
  "license": "MIT",
  "tags": ["cards", "starter", "sandbox"]
}

See also#

modManifestSchema#

Exported from @diceytable/shared as modManifestSchema. 93 fields across 15 tables.

Field Type Required Default Min / Max Pattern Rule Description
schemaVersion "1.0" yes
id string yes 3–96 chars ^[a-z0-9][a-z0-9._-]*[a-z0-9]$
slug string no 1–60 chars ^[a-z0-9]+(?:-[a-z0-9]+)*$
name string yes 1–80 chars
version string yes ^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$
type "game-pack" | "component-pack" | "table-pack" | "room-pack" yes
entry object yes
assets string[] no [] <= 2000 items; each 1–180 chars
assetManifest modAssetRefSchema[] no <= 2000 items
excludedAssets string[] no <= 2000 items; each 1–180 chars
assetRepos declaredAssetRepoSchema[] no <= 4 items
pluginSettings Record<string, Record<string, string | number | boolean>> no
compatibility object yes
capabilities object no {"version":"1","allowed":["log"]}
license string yes 1–80 chars
tags string[] no [] <= 20 items; each 1–32 chars ^[a-z0-9-]+$
summary string no <= 200 chars
description string no <= 20000 chars
category modCategorySlugSchema no
players object no players.max must be greater than or equal to players.min.
coverImage string no 1–180 chars
screenshots modScreenshotSchema[] no <= 12 items
rules modRulesDocumentSchema[] no <= 8 items 1 further cross-field rule (message built at validation time).
dice modDiceEntrySchema[] no <= 7 items Each die may be declared once.
diceEnabled boolean no
soundSets modSoundSetSchema[] no <= 64 items
plugins modPluginUseSchema[] no <= 8 items
dependencies modDependencySchema[] no <= 24 items
setupOptions modSetupOptionSchema[] no <= 12 items
lobbyRequirements modLobbyRequirementSchema[] no <= 4 items
credits assetCreditSchema[] no <= 500 items

Whole-object rules:

  • 1 further cross-field rule (message built at validation time).

schemaVersion#

This is the version of the manifest format, not of your mod — version is the number you bump when you publish. There is one accepted value and no migration path, so a manifest carrying anything else fails to parse before any other field is read: the scan reports a literal mismatch and tells you nothing about the field you were actually editing.

Copy it verbatim into every new manifest and leave it alone.

id#

The address of your mod. The registry keys on it (upsertScannedMod, apps/server/src/store.ts), the public page routes on /games/<id> whenever no slug is set, and favorites, reviews and play stats all hang off it.

Changing it publishes a second mod rather than renaming the first: re-scanning the same repo writes a fresh record under the new id, and the old record keeps the favorites, the reviews and the URL people bookmarked. Pick it once, before anyone plays — and pick it as an identifier, not a title. name is the part a reader sees.

slug#

The pretty half of the public URL: with a slug your details page lives at /games/<slug>, without one it lives at /games/<id>. Both forms keep resolving once a slug exists, so an id link you shared early never dies.

The collision case is quiet rather than loud. Availability is checked against every other mod's id and slug (checkModSlugAvailability, apps/server/src/store.ts), and when a desired slug is already taken the persist path drops it and stores the mod's own id as its public slug instead. A mod whose URL didn't change after you set a slug lost a race for that word — pick another. Treat the slug as a stable identifier too: changing it moves the page every existing link points at.

name#

The human-readable title — the card heading, the details-page headline, and one of the strings free-text search matches against. Nothing resolves a mod by it: id addresses and slug routes.

That makes it the one naming field you can change freely between releases. Rename it the moment the old title stops describing the game.

version#

Your mod's own release number. It is unrelated to schemaVersion (the manifest format) and to compatibility.engine (the engine you target). Play still serves whatever the registered ref points at, so bumping this doesn't by itself change what a running table loads.

But the label is now immutable once published. A version label is a permanent name for the exact commit it was first published at: re-publishing the same label from a different commit is refused with a 409 mod-version-immutable, and you have to bump this field instead. That is what makes a dependency pin mean something — a dependent names (packId, version) and nothing else, so a label that could be repointed would change what every dependent loads without any of them touching their pin. See Fixing a Rejection.

One more thing reads it — the editor copies it onto the publish-history entry it writes (manifestVersion in createProjectVersion, apps/server/src/store.ts). Treat it as a changelog for humans, and move it whenever you push a change you want to be able to point at later.

type#

A one-word statement of what you published: game-pack for a playable game, component-pack for a library of reusable assets other mods draw on, table-pack for a table and its play surface, room-pack for a room — floor, ceiling, walls, lighting and decor. Pick the one that tells a browsing player what they are getting.

The value reaches a reader in two places: free-text search matches against it alongside name, id and tags, and the mod list prints it beside the scan status.

Two rules do branch on it, and both are refusals.

A room-pack and a table-pack carry no code. Declaring an entry.script is rejected, and so is any capability beyond log. That is not a style rule — the capability disclosure a player sees is derived from the mods a room lists, which does not include what those mods depend on, so a script hiding inside a depended-on pack would run with none of the disclosures firing. If your room needs a script, the script belongs to the game pack that uses the room.

Your type is fixed once you publish. Re-publishing under a different type is refused. A dependent pins a version, not a type, so a room pack that turned into a game pack would change what everyone depending on it loads — and what capabilities they inherit — with no version bump and nobody told. If you need a different kind of pack, publish it under a new id.

entry#

The two pointers from the manifest into your repo: the file the table loads, and the file the sandbox runs. Both members are optional, and an empty entry is valid — an asset-only mod has neither.

That validity is the trap. The scanner skips whichever branch is absent and falls through to compatible, so a mod with no setup publishes cleanly and puts nothing on the table. When a mod scans green and plays as an empty room, read this field before you read anything else.

assets#

Declaring a file here is what makes the scanner responsible for it: every listed path is fetched, size-checked, content-type-checked and hashed at registration, so a path that 404s on the published ref is a rejection you see at publish time instead of a missing texture a player sees at the table. Players download everything listed here before the table opens.

You do not maintain this list. It is derived: the editor recomputes it from your project's file tree on every save, and the publish endpoint recomputes it again from the pushed tree. Every table asset in the tree is declared automatically; the manifest itself, the entry files, rulebook pages, and editor-only files (originals/, thumbnails/, screenshots/, a deck's per-card source images, link records) never are. A path you add or remove by hand is overwritten by the next save.

The one decision that is yours is the opposite one — keeping a declarable file out. That is stored in excludedAssets, which the reconciler honours, and it is set per file on the manifest editor's Files tab.

What surprises people is the fate of a file that is not declared. It still loads — AssetResolver falls back to raw.githubusercontent.com/{owner}/{repo}/{ref} for any repo-relative path — but it arrives unverified, uncached, and one request at a time, the first time something asks for it. The bytes stay in your GitHub repo throughout; nothing here uploads them to our servers. See Assets for the extension allowlist and the size cap.

assetManifest#

An optional catalog of the same repo files, one entry per file, that upgrades the play-time download from "fetch what's declared" to "fetch what's declared and check it arrived intact". When it is present and non-empty it replaces assets as the pull list (installModAssetResolver, apps/web/src/ui/App.tsx), and each downloaded file is verified against its recorded hash before it enters the local play cache.

Keep it in step with the bytes you publish. It describes files in your GitHub repo rather than copying them anywhere, and an entry whose hash no longer matches its file costs you the cache rather than the asset — the resolver still reaches the file on GitHub, session after session, without ever storing it.

excludedAssets#

The files you have chosen not to pre-load — the one manual decision about assets that survives a save.

assets is a derived list: the editor recomputes it from your project's file tree on every save and the publish endpoint recomputes it again from the pushed tree, so removing a path from assets by hand only lasts until the next reconcile puts it straight back. "Do not pre-load this one" therefore cannot live in assets. It lives here, and the reconciler reads this list and leaves those paths out.

An excluded file is not removed from your pack. It is still pushed to your repository and it still works at the table: AssetResolver fetches it the first time something uses it. What it skips is everything declaring buys — it is not fetched, size-checked and hashed at registration, and players do not download it before the table opens. That is the trade. It suits a large file most sessions never touch (an optional expansion's board, an alternate art set); it is the wrong choice for anything on the table at load, which would then arrive late and unverified while a player watches a placeholder.

You do not hand-write this either. Open Mod ▸ Mod Details…, go to the Files tab, and switch Pre-load off for the file — its state changes from Pre-loaded to On demand.

The list maintains itself the same way assets does:

  • an entry whose file has been deleted is pruned, so this never names a file the tree does not have;
  • an entry follows its file through a rename or a move;
  • an entry that could never be declared anyway — an entry file, a thumbnail, anything under originals/ — is dropped;
  • when the last entry goes, the key is removed rather than left as [].

Every pack type may use it; it is not one of the type-restricted fields.

An older server ignores it rather than refusing it. A server built before this field existed strips the key from its stored copy and re-declares the files, which is simply the old behaviour: your exclusion is ignored and the files are pre-loaded. A manifest carrying excludedAssets is never rejected for it.

"assets": ["assets/board.png", "assets/models/die.glb"],
"excludedAssets": ["assets/expansion/board-xl.png"]

assetRepos#

Almost every pack omits this. It exists for one case: a card catalogue whose art lives in somebody else's GitHub repository — set images maintained by a community project, say — rather than in your own. Those rows carry absolute raw.githubusercontent.com URLs, so they never travel the repo-relative path the asset CDN normally routes, and the CDN would refuse them anyway because the repo behind them is not one this platform registered.

Listing a repo here does exactly two things. It puts that coordinate on the edge's allowlist, and it lets the client rewrite absolute GitHub URLs naming it onto cdn.diceytable.com. It grants no read a player's browser did not already have: everything it admits is public on GitHub and fetchable from the same URL by the same browser. What it changes is whose bytes we are willing to serve from our domain — which is why it is capped at four entries, shown on your pack's page, and dies with the pack that declared it. A blocked pack's declarations stop being served with it.

Declared, never inferred — but you rarely write it yourself. The CDN will not read your catalogue and work the hosts out for itself, deliberately: that would make the set of repositories we proxy a function of a data file nobody reviews. An entry here is a line in a public, commit-pinned manifest that a person reads once, which is the same property that makes a mod name a plugin function rather than a URL.

What the platform does instead is write that line for you when you publish from the editor. It reads every card catalogue (*.cards.json) your manifest lists under assets, takes the owner/repo/ref of each absolute raw.githubusercontent.com art URL exactly as the URLs spell it, and appends any coordinate assetRepos does not already cover to the committed diceytable.mod.json. The result is still an ordinary declaration in your published manifest, reviewed like any other line; nothing downstream infers anything.

  • Additive only. Entries you wrote are never removed or edited, even one no catalogue URL uses any more. Delete a stale entry yourself — it still counts toward the cap of four.
  • Literal refs. An art URL at refs/heads/release produces a refs/heads/release entry, never master or main, because the ref is compared literally.
  • Stops at four. Coordinates that do not fit, or that are not a valid declaration, are left out and reported by the scanner's catalogue-art-undeclared warning.
  • Stable. New entries are appended in sorted order, so republishing an unchanged pack does not change the file.

You still declare by hand any outside repository your art reaches other than through a card catalogue, and any pack you publish without the editor (pushing to GitHub yourself and registering the repo).

Undeclared art is not broken — it keeps loading straight from GitHub, exactly as it always has. It is only slower. See Asset Delivery and the CDN.

"assetRepos": [
  { "owner": "SWTCG", "repo": "SWTCG-LACKEY", "ref": "refs/heads/release" }
]

pluginSettings#

Your pack's answers to the settings a plugin declares, keyed by plugin id and then by setting key.

A plugin manifest may declare a small form — a select of which sets to include, a toggle for promos, a number for a page size. It declares the shape; your manifest supplies the values. Both are public, commit-pinned documents, so anybody reading your pack can see what is configurable and what you configured, without running anything.

You do not hand-write this. Open diceytable.mod.json in the editor (Mod ▸ Mod Details…), go to the Plugins tab, and fill in the Settings form on the plugin's card — the controls, their labels and their bounds all come from the plugin. The same card holds the functions your script may call (plugins), so attaching a plugin and configuring it are one place. A plugin you only use as a deck source, and never call, can be given answers with Configure only, which writes this key without adding a plugins entry. Remove on a card drops both.

Values are flat scalars only: string, number or boolean, matching the four control types. Nesting is refused for the same reason a deck's data bag refuses it — this travels with your pack and is read by other code, and a structure invites a settings form to become a smuggled document.

Two behaviours worth knowing:

  • Leaving a setting unanswered is normal. Every setting falls back to the default the plugin declared. If answers were required, a plugin could break every mod using it just by adding one.
  • An answer to a setting the plugin no longer declares is reported, not silently dropped. That is how a plugin's breakingVersion bump reaches you: the editor shows the stale key with a Remove button rather than quietly discarding configuration you wrote.

Configuring a plugin here does not enable it at a table — a table's owner still chooses that in Room & table.

A room-pack or table-pack may not declare it, for the reason plugins gives: a pack that runs no script can call no plugin, so there is nothing for these answers to configure. The schema refuses the key by name and the manifest editor has no Plugins tab for those types.

"pluginSettings": {
  "community.swtcg-deckdb": { "set_filter": "core", "include_promos": false }
}

compatibility#

Your declaration of which engine builds the mod is written against, tested once when the mod is registered or re-scanned. A mod that fails the test is marked incompatible, and an incompatible mod cannot be selected for a room.

The grammar looks like semver and is not — read Engine compatibility before you invent a range, because one natural-looking form matches every engine version there has ever been.

capabilities#

The block a reviewer — or a cautious player — reads before opening your code: a least-privilege statement of which parts of the mod api your script touches. The scanner cross-checks it against the literal api.<method>( call sites it finds in entry.script, and refuses both to register and to serve a mod whose script uses something the manifest doesn't declare.

Omitting the block is a decision rather than an opt-out. A manifest with no capabilities is granted log and nothing else, so a mod that leaves it out and calls api.createObject fails scanning. Mod capabilities covers all 11 slugs, the methods each one unlocks, and what a declaration does and does not guarantee at run time. One of them is worth naming here: read-hidden-information is the only way a mod reaches real card faces, deck order or secretMetadata, it is never implied by read-world, and a player reading your manifest will see it.

license#

Free text, shown as-is on your mod's details page. Nothing parses it: no SPDX check, no enum, no allowlist — "whatever I want" registers as readily as "MIT".

Because it is unchecked, it is also the field most likely to be quietly wrong. Write the identifier you actually mean, and make it the one that matches the LICENSE file in the repo you are publishing.

tags#

Free-form keywords, and the manifest's only working filter: the details page links each tag to /games?tag=<tag>, and discovery matches a tag query against your list case-insensitively (discoverMods, apps/server/src/services/modService.ts). They also join name, id and type in the free-text search haystack.

Use the words a player would type, not the words you use internally. category is the other half of the pair — one curated slug from a fixed taxonomy, where tags are many and entirely yours.

summary#

The one line under your mod's title on cards, the games list and the details page. It renders through the inline markdown renderer, so **bold**, `code` and links come through while paragraphs and headings do not — the field stays on one line by construction.

Leaving it out costs you the first sentence a browsing player reads: the card falls back to printing your tags joined by commas, and to "No description provided." when there are no tags either. Write the sentence that makes someone click.

description#

The long-form body of the details page, rendered as markdown under an "About" heading — the only field with room for rules, credits and attribution. Raw HTML in it is escaped rather than executed, and links are restricted to http, https and mailto, so anything else you paste arrives as plain text.

Write it for someone deciding whether to play. The table never shows this text to the people already at it.

category#

One slug from a curated taxonomy, chosen to tell a browsing player what shape of game this is. It renders as the labeled chip on game cards, on the home page spotlight and on the details page.

What it does not do is filter. Discovery narrows on tags and scan status, and nothing queries by category today (discoverMods, apps/server/src/services/modService.ts). So pick the slug that reads best to a human and put the words you want to be found by into tags. The full list is on Mod categories.

players#

The player count your rules are written for. It renders as a chip on game cards and the details page, and rides along on the home page spotlight payload.

Nothing enforces it: seats belong to the room, so a six-seat table loads a mod that claims four without complaint. Treat it as a promise to a reader rather than a constraint on a session. The whole object is optional, and a mod that omits it shows no chip at all — for a game that plays at any count, that is the honest answer.

A game pack and a table pack use it; a room pack and an asset pack do not. The range describes how many people sit down to the thing — a table publishes it as its Seats, a room seats nobody (the table standing in it does) and neither does a box of parts. The editor drops the control for those two types rather than inviting an answer to a question the pack has none for. Nothing is refused over it, so a manifest that carries one anyway still publishes; the editor lists it as a key the pack cannot use, with a Remove button.

coverImage#

The square artwork that carries your mod everywhere it appears in a list — the game card, the home page spotlight, the details page hero. A mod without one draws a placeholder icon, and the spotlight prefers mods that have artwork over mods that don't, so this is the field that decides whether you are seen at all.

It needs no entry in assets. The scanner sweeps cover and screenshot targets separately and validates them as images wherever they are referenced from. Let the editor's media panel produce the file — it writes the square WebP and the thumbnail that pairs with it. See Cover art and screenshots.

screenshots#

The details-page gallery, shown in array order. The editor's move-left and move-right buttons reorder this array and nothing else sorts it, so whatever sits first is what most people see.

An entry is a screenshot, a video, or a video with a screenshot as its cover. It must be at least one of the two — an entry with neither a path nor a video is refused at publish rather than rendering as a gap in your gallery.

The files these paths point at are already cropped and resized to 16:9 by the editor's media pipeline before they are published; the page renders them as they are. The crop recorded beside each path is a note about how the frame was chosen, not an instruction applied at display time.

Videos share the strip with the stills, because the order is your pitch and we are not going to split it into two sections for you. A video entry draws its cover with a play button over it and loads nothing from YouTube until a visitor presses play — so a gallery full of trailers costs a browsing visitor no more than one full of screenshots. See video for how to add one.

"screenshots": [
  { "path": "media/screenshot-0.webp" },
  { "video": { "provider": "youtube", "id": "aqz-KE-bpKQ", "title": "Two-minute trailer" } },
  {
    "path": "media/screenshot-1.webp",
    "video": { "provider": "youtube", "id": "aqz-KE-bpKQ", "title": "Setup, start to finish" }
  }
]

rules#

The rulebook players read — on your game's public page, and from the Rules button at the table.

Declare a list. Each entry is one document with a title and one of four kinds, and a game usually publishes two or three: a quick-start, the full rules, a how-to-play video, an FAQ.

kind What you publish Best for
markdown A .md file at rules/<id>.md Rules you write and keep editing
pages Rendered .webp pages at rules/<id>/page-NNN.webp, plus the original PDF An existing PDF rulebook
link Nothing — just an https:// URL Rules already published elsewhere, or too big for the limits
video Nothing, or one cover image at rules/<id>/cover.webp A how-to-play video

Nobody downloads your rulebook until they open it. This is the point of the field, and the reason rules files are not listed in assets. Everything in assets is fetched before the table appears, with a progress bar counting every file — correct for a model, wrong for a 10 MB rulebook most players already know and never open. Rules files are fetched only when someone opens the Rules panel, and the list shows each document's size before they do. Once fetched they are cached like any other file, so the second look is instant and the installed app can read them offline.

Do not add rules files to assets. Declaring one there puts it back in the up-front download and undoes all of that. The editor will not do it for you: rules/ is excluded from the automatic asset reconciliation.

Upload a PDF and we convert it. The editor renders each page to a web-sized WebP — a fraction of the bytes, legible on a phone, and no PDF reader involved on the reading side, which is also why it works in the installed app. Your original PDF is published alongside as a Download the original PDF link whenever it still fits the budget; when it does not, the pages are published without it and the editor tells you so rather than failing the upload.

The limits, and what to do when you exceed one. A PDF may be up to 20 MB and 120 pages on upload, and one finished document — every page plus the preserved original — may be up to 10 MB. Past any of those, upload the file to a file host (Google Drive, Dropbox, your own site) and add it as a link document instead. That is a first-class option, not a consolation: the Rules panel shows the destination host and lets the reader follow it.

A video rulebook plays in place. Add a video document, paste a YouTube link, and the Rules panel draws a cover with a play button; the player loads only when the reader presses it, so a rulebook video costs a player who never opens it nothing at all. Nothing of the video is published — only an optional cover image of your own, which is worth uploading when you want a particular frame rather than whatever YouTube picked. The panel always offers a Watch on YouTube link beside the player, because an embed can be refused by the uploader's settings or by a viewer's network and a reader who presses play needs somewhere to go.

A game may also put a video in its screenshots gallery. Use the gallery for the trailer — what the game looks like — and a video rulebook for the tutorial someone opens mid-game to check a rule.

A link must be https://. The pages that render it are HTTPS, so a plain-http rulebook is mixed content and fails silently in every modern browser. It is refused at publish, where you are still around to fix it.

Only a game-pack may declare rules. The Rules button resolves the room's game — its provider mod, or the one selected mod — and the public Rules section renders on a game's page. A room, table or asset pack is a dependency of a game rather than the game, so rules on one are opened by nobody; the publish scanner refuses them with rules-unread, and the editor does not offer the section for those types.

Every file is fetched, type-checked and hashed at publish like any other published byte, so a missing page or a mislabelled file is refused then rather than discovered by a reader. Omit the field and your game simply has no Rules button.

"rules": [
  {
    "kind": "markdown",
    "id": "quick-start",
    "title": "Quick start",
    "path": "rules/quick-start.md"
  },
  {
    "kind": "pages",
    "id": "rulebook",
    "title": "Full rulebook",
    "pages": [
      { "path": "rules/rulebook/page-001.webp", "width": 1236, "height": 1600, "bytes": 148213 },
      { "path": "rules/rulebook/page-002.webp", "width": 1236, "height": 1600, "bytes": 151902 }
    ],
    "source": { "path": "rules/rulebook/source.pdf", "bytes": 2418877, "filename": "rulebook.pdf" }
  },
  {
    "kind": "video",
    "id": "how-to-play",
    "title": "How to play in five minutes",
    "video": { "provider": "youtube", "id": "aqz-KE-bpKQ" },
    "cover": { "path": "rules/how-to-play/cover.webp", "width": 1280, "height": 720, "bytes": 62104 }
  },
  {
    "kind": "link",
    "id": "faq",
    "title": "Living FAQ",
    "url": "https://example.com/my-game/faq"
  }
]

dice#

Which dice this game uses — the list the table's dice picker offers instead of the standard set.

Declare an array. Each entry names one standard die preset, and may rename it and preselect a starting count:

Field Required What it does
preset yes Which die. One of the seven standard dice presets.
label no What the picker prints for it, when the preset's own name is not the word your game uses.
defaultCount no How many the picker starts at, so the common roll is one click.

Absent — or empty — means the standard d4–d20 set, not "no dice". This is the single most important thing to know about the field. Leaving dice out is the normal case and gives every game the six ordinary dice; publishing dice: [] does exactly the same thing, on purpose, because an empty picker looks broken in precisely the situation (a stripped-down write from a tool) where it is most likely an accident. To turn dice off, use diceEnabled: false, not an empty list. The switch leaves this list alone, so turning dice back on restores the dice you chose.

You don't need to write this by hand. The editor's Game row has a Dice panel that edits both fields.

Declare the field when the default set is wrong for your game, which is most often because it is too wide. A game that rolls two d6 and nothing else is easier to play when the picker offers d6 starting at 2 than when it offers six dice starting at zero. Renaming is the other reason: label lets a d6 read Combat die if that is what your rules call it.

A die may be declared once. A list naming die-d6 twice is refused at parse with Each die may be declared once. — two rows of the same die in a picker is never what an author meant, and the second one is usually a preset they forgot to change.

It costs a joining player nothing. A dice list declares no assets and downloads no bytes; it is a handful of preset ids that configure a picker built from dice the app already ships. That is the whole reason a manifest may name only presets and not describe geometry — see preset.

Only a game-pack may declare dice. The picker offers the dice declared by the room's game — its provider mod, or the single selected mod — and a room, table or asset pack is a dependency of a game rather than the game. A list declared on one of those is offered to nobody, and, worse, is invisible: the picker goes on showing the default d4–d20 set with nothing to say why the dice you curated never appeared. The publish scanner refuses it with dice-unread.

"dice": [
  { "preset": "die-d6", "label": "Combat die", "defaultCount": 2 },
  { "preset": "die-d20" }
]

diceEnabled#

Whether this game rolls dice at all. Set it to false for a game that has no dice.

Off does three things:

  • The table's Dice toolbar menu is not shown.
  • The VR fist dice panel never opens.
  • The host refuses every dice-roll, including its own. Hiding the button is only presentation. The refusal is what stops a stale client or a hand-built intent from rolling anyway.

It does not remove dice that are already on the table, and Clear my dice still works. Clearing only removes dice.

Absent means on. Every game published before this field existed keeps its dice button, so leave the key out rather than writing true. The editor's Dice panel removes the key when you turn dice back on.

It is a separate flag, not an empty dice list. An empty list already means "the standard set". Keeping the switch separate also means turning dice off and on again leaves your curated list exactly as it was.

Only a game-pack may switch dice off, for the same reason only a game pack may declare dice. The table reads the switch from the room's game, so a room, table or asset pack that sets it switches dice off for nobody. The publish scanner refuses it with dice-unread.

"diceEnabled": false

soundSets#

Where a mod declares its own audio: a logical name bound to the clips you uploaded into your repo, optionally tagged with the material and the action they represent. A script reaches them with api.playSound({ modSound }), or attaches one to an entity with api.setObjectSound(...).

A mod can never name a clip from the first-party sound library — that is a licensing boundary rather than an oversight, and it is why every play form is semantic. The host also checks a sound reference against the declaring mod's own names, so one mod cannot override another's audio. See Sound sets.

plugins#

Which plugins this mod calls, and exactly which of their functions.

A mod has no network of its own and never will. plugins is how it declares the one external reach it can have: naming a function that an installed plugin already declared, which the platform then performs from its own servers, to an origin the plugin declared, under that plugin's quota and circuit breaker.

Declaring the plugin alone would not be a declaration worth having — a plugin's exposed surface grows over releases, so { "id": … } on its own would silently widen every time the plugin author shipped a new function. Listing the functions makes a mod's external reach a fixed set that changes only when the mod is re-published and re-scanned, which is what lets a reviewer read this array and know the whole of it.

You rarely write this by hand. The manifest editor's Plugins tab has Add to game for a registered plugin and a checkbox per function, and it keeps plugin-call in capabilities.allowed in step with what you tick.

The publish scanner enforces it in both directions. It reads every api.callPlugin("…", "…") out of the script text and rejects any pair this array does not declare (undeclared-plugin-call), and rejects any call site whose target it cannot read as a literal (dynamic-plugin-call). The runtime enforces it again: an undeclared pair resolves not-found, indistinguishable from a plugin that is not installed.

Declaring a function here requires the plugin-call capability. An entry with "functions": [] — a plugin used only for the files it declares under resources — declares no call and does not require it, so it adds nothing to the read-hidden-information + plugin-call disclosure either. Omitting the field entirely means this mod calls no plugins, which is the default and the fail-closed reading.

A room-pack or table-pack may not declare it at all. Those types carry no script (CAP-1), so they can call nothing — and plugin-call is itself a capability they may not declare. The manifest schema refuses the key by name, at the field you wrote it in, rather than letting it fail one field over as a missing capability you would then be tempted to add. A component-pack (an asset pack) may: prefabs carry scripts. The whole per-type table is packages/shared/src/modTypeFields.ts; the same rule covers pluginSettings.

dependencies#

Published packs this mod builds on, each pinned to one exact version.

A pack is a mod — same repo, same manifest, same registry row — so depending on one is how you reuse someone else's room, table or component library instead of vendoring a copy. Omit the field entirely and you depend on nothing, which is what every manifest published before this field existed means.

Pins are exact, and there are no version ranges. ^1.2.0, ~1.2, 1.x and >=1.0.0 are all rejected. A range would hand you the thing this model exists to prevent — an upstream publish silently changing your game — and it would make a table non-reproducible: two players who resolved the range at different times would load different packs, and nothing in the session would notice. You move to a new version by editing the pin, and only then.

A dependency may have dependencies of its own, and there it stops. The graph is capped at two levels: you, what you depend on, and what that depends on. A pack three levels down is refused by name rather than quietly left out — depend on it directly, or vendor it. Cycles are refused for the same reason: a graph that reaches itself has no order in which its packs can be composed.

Three more rules the publish check enforces, all of them named refusals rather than silent fixes:

  • A pack cannot depend on itself, and cannot list the same pack twice.
  • Two different pins of the same pack anywhere in your graph are refused. One pack, one version.
  • A dependency that cannot be resolved is an error at publish, not a surprise at play.

Licences are surfaced, never enforced: you acknowledge a dependency's licence when you add it, and the attribution on your listing is derived from this list, so there is no credit field for you to forget.

setupOptions#

Per-game options the host chooses in the lobby, before the table exists.

The manifest declares the questions — a small, typed form. The answers are chosen once per room and live on the room record, not in the snapshot and not in mod saved data: they are session configuration, decided before anything is on the table, read once at setup and never mutated by play.

Delivered to a running table. The answers reach your script as the third argument of setup(api, manifest, options), resolved on the host from the room record and your own manifest and handed to the sandbox with the message that runs your file. Every option you declared is present, because a declaration always carries a default.

The form is drawn in the lobby, under Game options, from your declaration: the room's owner answers it before pressing Start, and an owner who touches nothing leaves every option at its default — which is why a sensible default on every option is what the schema requires in the first place.

Setup options are the OWNER'S answers, one set for the room. For a question each player answers for themselves — "which deck are you bringing?" — see lobbyRequirements, which is enforced: Start is refused until everyone playing has answered.

🔴 A scriptless mod can never read an option, and the scanner now refuses it. Only a mod with an entry.script has anywhere for an answer to arrive. A pack whose entry is a setup.json alone has no code at all — instantiating a setup file places the entities it names and consults nothing else — so an option declared there would be answered by a host and then read by no one. A typical checkers pack is exactly this shape: entry: { setup }, no script, capabilities: ["log"]. Declaring setupOptions on such a mod registers it as incompatible with the setup-options-unreadable error, which names the first option key and tells you to add the script or drop the block. A room-pack or table-pack may not have a script at all, so its refusal says to remove the options or republish as a game pack. The refusal is deliberate: a silently ignored options block is worse than being turned away, because a host configures something that does nothing and nothing anywhere says so.

A credential pasted into an option is refused too. Defaults, select choice values and labels are walked by the same tripwire the plugin registry uses, and anything credential-shaped is a manifest-secret error. A manifest is public and, once registered, pinned to an immutable commit — a key committed here is a published key.

The vocabulary is the same four controls a plugin declares its settings with, and for the same reason: a mod says which form element, never how it looks or where it sits. Every control here is meant to render as one of our own components, so a manifest can configure a game without becoming a page.

Omitting the field means this mod offers no choices. That is the default and the fail-closed reading: nothing to ask, and nothing for a script to read.

lobbyRequirements#

What each player has to choose before the table opens.

The sibling of setupOptions, and the difference between them is who answers. A setup option is one question the room's owner answers once for everybody, delivered to your setup(). A lobby requirement is one question each seated player answers for themselves, and the room owner cannot press Start until they all have.

A deck requirement puts a picker in the lobby. It lists decks for your game — read with that player's session, not the host's — narrowed by anything you declare here. What they choose shows on the lobby roster, so everybody can see who is still deciding, and it is put on the table for them as they walk in, through the ordinary deck-load path.

Where the picker looks: deckSources

By default the picker offers the player's own decks and nothing else, which is what every pack published before this field shipped with. deckSources widens it, and its order is the tab order:

Source What it lists
mine The player's own decks for this game, at any visibility. Owner-scoped by the server.
community Every deck other players made public for this game. Never anybody's private decks.
database Public lists on an outside provider, through a plugin you have already declared.

A wider pool is never a wider read, and your manifest is not what makes that true. mine is scoped to the caller in the statement itself and community is a bare "public only" predicate, so there is no declaration that could reach somebody else's private deck. Widening is a product decision, not a permission.

database — an outside deck site, through your plugin

Name the plugin and the function that lists its public decks:

"deckSources": ["mine", "community", "database"],
"deckDatabase": {
  "pluginId": "community.swtcg-deckdb",
  "listFunction": "publicDecks",
  "label": "swtcg-deckdb.com"
}

🔴 This is a NAME, never a request — the same contract every other plugin call follows. The chain is requirement → plugin id + function name → plugin manifest → endpoint → origin + path template, all declared data, and the fetch happens server-side under that plugin's own origin allowlist, quota and circuit breaker. There is no URL here and there will not be one.

The plugin and the function must both be in your plugins block. A requirement is not a second way to reach a plugin you have not declared: publish refuses one that is missing, naming it. That block stays the whole of your pack's external reach, readable without running anything.

The listing function must take no required parameters and return rows carrying at least an id and a name. It is called once when the tab opens and searched in the browser — providers that publish their whole public list as one document (the common case, and the reason this exists) have no search endpoint to call, and turning keystrokes into provider requests would spend that plugin's shared daily budget on typing.

Picking a result imports it. The platform asks your plugin's declared deckImport block for that list, joins it against your game's own card catalogue in the player's browser, and saves the result as a private deck of theirs. So the answer that reaches the roster is an ordinary deck reference like any other, and the player keeps the deck afterwards instead of borrowing it for one game. Cards the provider names that your catalogue does not have are left out and said so — never guessed. A provider whose plugin declares no deckImport cannot be a database source.

This is the part a mod script cannot do. api.setUiElement is host-only, so a picker drawn from inside the sandbox is necessarily built by the host's copy of your script — which means every deck it can list was read with the host's credentials. That is a real constraint, not an oversight, and it is why the packs that ship their own deck dialog offer "my decks" to the host alone. Declaring it here moves the ask to the lobby, where each person's own client reads their own library.

Unlike setupOptions, a pack with no entry script may declare these. A requirement is asked, checked and applied entirely by the platform, so there is nothing here that needs a script to read it.

But only a game-pack may declare them at all. Requirements are read from the single mod the lobby resolves as the room's game — its provider, or the one selected mod. A room, table or asset pack is a dependency of a game rather than the game, so a requirement on one is asked of nobody; the publish scanner refuses it with lobby-requirements-unread, and the manifest editor does not offer the field for those types. Scriptlessness and being the game are two different axes: a game pack with no script is fine here, and an asset pack full of scripts is not.

Nothing is enforced client-side. Every answer is re-checked by the server against this declaration, and Start is refused with lobby-requirements-unmet while a required one is outstanding. The greyed-out row in the picker is courtesy; the refusal is the rule.

Omitting the field means your game requires nothing, and a lobby for it looks exactly as it did before this existed. At most four may be declared: a lobby that asks more questions than that has stopped being a lobby.

"lobbyRequirements": [
  {
    "id": "deck",
    "kind": "deck",
    "label": "Your deck",
    "help": "60 cards, one side. Build one on this game's Decks tab.",
    "required": true,
    "minCards": 60,
    "deckSources": ["mine", "community"]
  }
]

credits#

Where the files in your mod came from, and who takes responsibility for them.

You do not write this by hand. Every time you import a file in the editor — art, a model, card images, audio, cover art — a window asks where it came from before the file is written into your project, and your answer is recorded here. It is published in your manifest, so anybody reading your mod can see it, and it is what the Credits tab on your game's page shows.

Each entry covers one import, which may be one file or four hundred, and says one of three things:

  • own-work — you made it, or you hold the rights to publish it.
  • third-party — somebody else made it. This one requires a creator, a link to them (creatorUrl or sourceUrl), and the licence it is used under (licenseName or licenseUrl). A credit nobody can follow credits nobody, which is why the link is not optional.
  • disclaimed — you are publishing it without saying. This is allowed, and it costs a signature: you accept full responsibility for the file and indemnify DiceyTable and its owner. The Credits tab shows these entries as what they are rather than hiding them.

Every entry carries an attestation — the version of the wording you accepted, when, and the name you signed with. That is what makes the record mean something later, so the texts are versioned and never edited in place.

license is a different field and the two get confused constantly. license covers the mod — your rules text, your arrangement, your own work. credits covers the files, most of which you may not have made. A mod can be MIT and still contain art nobody may redistribute.

Absent means no credits have been recorded, which is the honest state for every mod published before this existed — never a claim of authorship. The editor lists uncredited files under Not yet accounted for in the Credits tab; re-import one to record where it came from. Publishing is not blocked over it.

Nothing here is checked for you. DiceyTable does not verify that a named licence permits what you are doing, and it does not review the files you import. What the record does is make the claim yours, attributable and public.

modManifestSchema.entry#

Field Type Required Default Min / Max Pattern Rule Description
setup string no 1–180 chars
script string no 1–180 chars

entry.setup

The file the table loads to place your starting entities. Two formats are accepted, and they are told apart by content rather than by filename: the editor-authored scene the Edit Mode shell writes, or the older hand-written setup document.

Leave it out and the mod loads to an empty table — correct for an asset-only mod, and the first thing to check when a game you published starts with nothing on it. Setup JSON covers both formats and how each one is validated.

entry.script

The JavaScript file the sandbox runs, entered through exports.setup(api, manifest). It has to end .js: any other path is the hard unsupported-script-type rejection, "Sandboxed mod scripts must be JavaScript files."

There is no build step on our side. If you write TypeScript, compile it yourself and publish the emitted .js alongside the manifest. TypeScript belongs to the other surface — Table Scripting is authored and compiled inside the editor and uses world/globalEvents rather than api; see Choosing a surface to decide which one you want.

modManifestSchema.assetRepos#

Field Type Required Default Min / Max Pattern Rule Description
owner string yes 1–39 chars ^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$
repo string yes 1–100 chars ^[A-Za-z0-9._-]+$
ref string yes 1–120 chars ^(?:refs\/(?:heads|tags)\/)?[A-Za-z0-9._/-]+$

Whole-object rules:

  • 1 further cross-field rule (message built at validation time).

assetRepos.owner

The GitHub account or organisation that owns the repository — the first path segment of its URL.

Matched case-insensitively, because GitHub serves SWTCG and swtcg as the same account and an allowlist that folded one but not the other could be walked round by changing capitalisation. Write it the way the repository owner writes it; nothing depends on which case you choose.

assetRepos.repo

The repository name — the second path segment of its URL, with no .git suffix and no owner prefix.

Case-insensitive for the same reason as owner. The repository must be public: the edge fetches it unauthenticated, exactly as a player's browser would, and holds no credential that could reach a private one.

assetRepos.ref

Which commit, branch or tag the art is read at. A commit sha, a bare branch or tag name, or a fully-qualified refs/heads/<name>.

The ref is matched exactly, and that is load-bearing. Declaring main does not authorise a sha, and a sha does not authorise main — they are different bytes. If your art URLs address a branch, declare that branch.

A sha is preferred and a branch is allowed. Registration emits an asset-repo-unpinned warning for a branch rather than refusing it, because you often cannot pin somebody else's repository: the day they publish a new set, every card in it would render blank until you re-published your pack just to move the pin. The trade-off the warning is telling you about is caching — the edge holds a sha-addressed file for a year and a branch-addressed one for minutes, and a branch's contents can change without your pack being re-published or re-reviewed. Pin a sha whenever the repository is one you control.

Pin the full 40-character sha. An abbreviated sha draws the same warning and gets the branch cache: cafe1234 is also a legal branch name, and GitHub resolves either. A full sha earns the year-long cache only once GitHub's commits API confirms it is a commit in that repository.

modManifestSchema.compatibility#

Field Type Required Default Min / Max Pattern Rule Description
engine string yes 1–40 chars

compatibility.engine

The range string the scanner tests against the server's ENGINE_VERSION — the whole of what compatibility carries today. It is read once, when the mod is registered or re-scanned; an already registered mod is not re-tested as the engine moves under it.

It looks like semver and is not. The parser reads at most one >= bound and one < bound out of the string and ignores everything else, so a range with no bound it recognizes matches every version silently. Engine compatibility gives the exact grammar and the form to write.

modManifestSchema.capabilities#

Field Type Required Default Min / Max Pattern Rule Description
version "1" no "1"
allowed modCapabilitySchema[] no ["log"] <= 20 items

capabilities.version

Versions the shape of the capabilities block itself, so a future revision of the grant format could be told apart from this one. There is a single shape today and no second version to migrate to.

Nothing beyond the schema literal reads it. Write it and move on — allowed is the field in this block that decides anything.

capabilities.allowed

The list that decides which parts of the mod api your script is permitted to touch: log, spawn-object, register-action, read-context, read-world, read-hidden-information, object-action, saved-data, subscribe-events, ui and play-sound.

The check runs in one direction only. Declaring a capability you never use is never flagged; calling a method whose capability you didn't declare is refused at registration and again every time the artifact is served, so a script edited after a green scan fails to load rather than running with more reach than it declared. Least privilege here is a discipline you keep — start from what your script calls today and extend the list when you add a call.

Two of the eleven deserve a second look before you commit the list. read-world no longer means "the host's view of the table": all six of its reads are redacted to the least-privileged view on every peer, so a mod that declares it can count a hand but never identify one. read-hidden-information is the elevated read — it gates api.getUnredactedSnapshot() alone, is never implied by read-world, and is what a player reading your listing will understand as "this mod can see hidden cards". Declare it when a rule genuinely has to adjudicate a secret, and take it back off when that call goes. Mod capabilities has the per-capability method matrix and the exact enforcement each one gets at run time.

modManifestSchema.players#

Field Type Required Default Min / Max Pattern Rule Description
min integer yes >= 1, <= 64
max integer yes >= 1, <= 64

Whole-object rules:

  • players.max must be greater than or equal to players.min.

players.min

The smallest table your rules actually work at. Set it to the count below which the game stops functioning — the count where a trading game has nobody to trade with — rather than to the count you happen to have playtested most.

When min and max are equal the chip collapses from a range to an exact count ("4 players"), which is the right shape for a game that plays at one number and no other.

players.max

The largest table your rules cover — a claim about your design, not a limit on the room. Nothing stops a sixth player sitting down at a mod that says five; seats belong to the room, and this number tells a browsing player what to expect.

Set it to the count past which your game breaks rather than to the number of seats a table can hold. If it lands below min the manifest fails to parse as a whole, so a transposed pair surfaces as a publish failure rather than as a strange chip nobody notices.

modManifestSchema.dice#

Field Type Required Default Min / Max Pattern Rule Description
preset "die-d4" | "die-d6" | "die-d8" | "die-d10" | "die-d12" | "die-d20" | "die-piecepack" yes
label string no 1–40 chars
defaultCount integer no >= 0, <= 40

dice.preset

Which die this entry is, named as a standard preset id. Required, and the only required field of an entry.

The seven that exist are die-d4, die-d6, die-d8, die-d10, die-d12, die-d20 and die-piecepack. The enum is derived from the standard preset table's dice family rather than hand-listed, so it is exactly the dice the app ships — an id outside it is refused at parse.

die-piecepack is a d6 in a wooden coat: it is a sixth-sided die with different art, and it is deliberately not part of the default set, because a picker offering two visually different six-siders reads as a bug. You can still declare it explicitly, which is the point of it being in the enum.

A manifest names a preset; it cannot describe a die. That is a deliberate limit, not an oversight. Face values are read off a per-preset table of authored face normals, so a die that is not one of these seven has no entry in it and always settles unreadable — a roll that can never report a number. Dice imported as custom models have exactly that behaviour today, honestly reported as a null face rather than papered over, and letting a manifest mint one would multiply the problem rather than solve it.

The face count comes from the preset too, so a die-d20 entry always rolls twenty faces; there is no field that changes it.

dice.label

What the picker prints for this die, when the preset's own name is not the word your game uses.

Optional, 1–40 characters, trimmed. Omit it and the die shows its standard name (d6, d20). Set it and your rules' vocabulary reaches the table: Combat die, Fate die, Hit die.

This is a display override only. It does not change which preset is rolled, how many faces it has, what the dice notation reads (notation groups by face count, so a renamed d6 is still d6), or what any script sees — a roll summary reports preset and sides, never this string.

Keep it short. It sits in a picker row beside a count stepper, so a long name is truncated rather than wrapped, and 40 characters is already generous for the label of one die.

dice.defaultCount

How many of this die the picker starts at. Optional; omit it and the row starts at zero, like every undeclared die.

An integer from 0 to 40. 0 is a legitimate value and means the same as omitting the field — the die is offered but not preselected.

Set it for the roll your game makes constantly, so the common case is one click instead of one click per die. A Yahtzee-style game declares die-d6 with defaultCount: 5; a game that rolls a single d20 declares defaultCount: 1. Leave it off for dice a player reaches for occasionally: a preselected die they did not want is worse than a stepper they have to touch, because it rolls before they notice.

The cap is 40, and a whole roll is capped at 40 dice as well. Every rolled die is a real physics body and a real entity in the snapshot — that is what makes rolled dice keepable rather than an animation — so a roll costs what spawning that many entities costs. Declaring large default counts across several dice produces a picker whose starting state is already at the ceiling.

modManifestSchema.plugins#

Field Type Required Default Min / Max Pattern Rule Description
id string yes 3–96 chars ^[a-z0-9][a-z0-9._-]*[a-z0-9]$
functions string[] yes <= 24 items; each 1–64 chars ^[a-zA-Z][a-zA-Z0-9_]*$

plugins.id

The plugin's id, in reverse-DNS form (org.example.cards) — the same grammar a mod id uses.

It must match the first argument of every api.callPlugin(...) call in your script, character for character. The scanner compares literals; it does not resolve aliases.

plugins.functions

The names of that plugin's exposed functions this mod calls. May be empty ("functions": []): that is a resource-only use — the mod uses files the plugin declares under resources and calls nothing. An empty list declares no (plugin, function) pair, does not require the plugin-call capability, and grants no call: an api.callPlugin naming that plugin is rejected at publish as undeclared-plugin-call and resolves not-found at runtime.

Each name must match the second argument of the corresponding api.callPlugin(...) call. List only what you actually call: this array is the reviewable statement of your mod's external reach, and padding it with functions you might use later widens that statement for no benefit.

modManifestSchema.dependencies#

Field Type Required Default Min / Max Pattern Rule Description
packId string yes 1–96 chars
version string yes 1–40 chars Pin an exact version, for example 1.0.0. Ranges (^, ~, x, >=) are not supported: a range makes a table non-reproducible between two peers who resolved it at different times.
commitSha string no ^[a-f0-9]{40}$

dependencies.packId

The id of the published pack you depend on — the same id that pack's own manifest declares.

It names the pack, not a repo path and not a type. The registry resolves it to the repo and the commit, and it knows what kind of pack it is, so there is nothing to restate here that could disagree.

dependencies.version

The exact version label to pin, for example 1.4.0. Same shape as your own version.

A published label is immutable: once 1.4.0 exists it always means the same commit, so this one string is enough to make everyone at the table load the same bytes. Ranges are rejected — see dependencies for why.

When a newer version appears you are told, and shown what changing the pin would move, but nothing resolves to it until you edit this field.

dependencies.commitSha

The 40-character Git commit the pinned version resolved to, recorded alongside the label.

Optional, and normally written for you at publish rather than typed. The label alone is already immutable, so this is belt and braces: it keeps the pin reproducible even if a tag is later moved in the upstream repo, and it is what lets a peer verify it fetched the same tree everyone else did.

modManifestSchema.setupOptions#

Field Type Required Default Min / Max Pattern Rule Description
key string yes 1–64 chars ^[a-z][a-z0-9_]*$
label string yes 1–80 chars
control "text" | "number" | "toggle" | "select" yes
help string no <= 240 chars
default string | number | boolean yes
options pluginSettingOptionSchema[] no <= 32 items
min number no
max number no
step number no > 0
maxLength integer no >= 1, <= 2000

setupOptions.key

The option's stable identifier, and the property name an answer will be filed under.

Lowercase snake_case, starting with a letter, 1 to 64 characters. The pattern is not house style — resolved values are assembled into a record by assignment, so a key that names something on the JavaScript object model has to be unrepresentable rather than merely discouraged. __proto__ cannot match the pattern at all; constructor and prototype can, and are refused by name. The resolved record is additionally built on a null prototype, so neither guard is carrying the weight alone.

Two options may not share a key. A repeated key would leave the chosen value ambiguous, so it is a refusal rather than a last-one-wins. Both rules are enforced when the manifest is parsed, which is today; see setupOptions for which parts of this feature are not yet wired.

setupOptions.label

The caption a host will read beside the control.

Plain text, 1 to 80 characters. It is specified to render as a React child, which escapes it — no markdown pass and no link, deliberately: a lobby control is not a place for an author to put a URL in front of players who have not started the game yet. The cap is enforced at parse time; the rendering is part of the lobby that setupOptions notes is not built yet.

It never reaches your script. A script receives values, not the form they were chosen in.

setupOptions.control

Which form element this option is answered with: text, number, toggle or select.

The same closed four-value vocabulary a plugin declares its settings with, shared as one definition rather than copied (settingControls.ts). Each maps to exactly one JSON type — toggle to a boolean, number to a finite number, text and select to a string — and that mapping is what every other check on this page is written against.

The modifiers are scoped to their control and are refused at parse time, not ignored, on the wrong one: options belongs to a select, min/max/step to a number, maxLength to text. A min on a toggle is an author who believes something is happening that is not.

setupOptions.help

One line of explanation to sit under the control.

Optional, capped at 240 characters, and prose only — like label it is specified to render as text, never as markup. Use it for the sentence that stops a host guessing what a setting does. Like label, it never reaches your script.

setupOptions.default

The value the control starts on, and the value a script gets for an option nobody answered.

Required, which is the one place this schema deliberately parts company with a plugin's settings. A form has to render every control with a value the moment it opens, and a mod must never observe undefined for something it declared. That makes resolution total: every declared option is present in the record resolveModSetupOptionValues builds, so a script will need no fallback of its own.

The default is validated against its own control and its own bounds when the manifest is parsed. A select default must be one of the declared choice values, a number default must sit inside min/max, and a text default must fit maxLength — a control that cannot render its own default is not a control. A string default is itself capped at 2000 characters, because an uncapped one is a megabyte of manifest riding into every render of the form.

setupOptions.options

The choices a select offers. Required for select, refused on anything else.

Literal data, capped at 32 entries — a choice list is written in the manifest, never fetched. Two choices may not share a value, and the option's default must be one of them. All three rules are enforced when the manifest is parsed.

The cap is the plugin one reused rather than re-chosen: an author moving a control between the two surfaces should not discover a different ceiling.

setupOptions.min

The lowest value a number option accepts. Optional, and number only.

At parse time it does two jobs: the option's own default must not fall below it, and a min above max is a refusal. Beyond that it is the bound the shared value check (validateModSetupOptionValues) applies to a host's answer wherever that answer is stored, and the reason an out-of-bounds stored value resolves to the default instead of reaching a script.

setupOptions.max

The highest value a number option accepts. Optional, and number only.

Enforced exactly as min is, at the same two points, and the option's own default must satisfy both.

setupOptions.step

The increment a number control moves in. Optional, number only, and must be positive — a step of zero or below does not describe an increment.

It exists here and not on a plugin setting because of who touches it: a lobby number is nudged by a player mid-conversation rather than typed once by an author in an editor, and a piece count that can land on 7.5 is a bug a script then has to defend against. Note that it constrains the control, not the value: nothing rejects a stored number for being off-step.

setupOptions.maxLength

The longest string a text option accepts. An integer from 1 to 2000, optional, and text only.

The option's own default must fit it, checked at parse time. It is also the bound the shared value check applies to a host's answer; an over-long stored value resolves to the default. Leave it out and the schema's own 2000-character ceiling on a text value still applies.

modManifestSchema.lobbyRequirements#

Field Type Required Default Min / Max Pattern Rule Description
id string yes 1–64 chars ^[a-z][a-z0-9_]*$
kind "deck" yes
label string yes 1–80 chars
help string no <= 240 chars
appliesTo "players" | "owner" no "players"
required boolean no true
formatId string no <= 64 chars
minCards integer no >= 0, <= 100000
maxCards integer no >= 1, <= 100000
deckSources ("mine" | "community" | "database")[] no ["mine"] 1–3 items
deckDatabase modLobbyDeckDatabaseSchema no

lobbyRequirements.id

The key each player's answer is filed under.

Lowercase snake_case, starting with a letter. Two requirements may not share one — answers are stored in a record keyed by this, so a duplicate would mean each overwriting the other, and the manifest is refused rather than letting that happen quietly.

The format is the same one setupOptions.key uses, for the same reason: answers are assembled by assignment, so an id that could name something on the object model has to be unrepresentable rather than merely discouraged. __proto__ cannot match the pattern at all, and constructor and prototype are refused by name.

Changing an id in a later version orphans every answer filed under the old one. Those are dropped rather than migrated — they belong to a question nobody is asking any more.

lobbyRequirements.kind

What is being asked for.

deck is the only kind today. It resolves against your game's own deck pool — the decks that player saved for this pack — and nothing you write here can widen that: the pool comes from the room's provider mod, not from the manifest.

It is an enum rather than a boolean so that "pick a faction", "pick a scenario" or "pick a warband" can join it without replacing anything. The lobby roster, the wire format and the editor form are all keyed by kind.

lobbyRequirements.label

The caption a player sees above the picker, and the name they are chased by.

Plain text. It is rendered as a React child and never as markup — not because escaping catches it, but because nothing in the lobby ever asks this string for markup.

It appears in three places, so write it as a thing, not an instruction: above the control ("Your deck"), on every member's roster row when they have not answered ("No your deck yet"), and inside the sentence that tells the owner why Start is greyed out ("sam (Your deck) still has to choose before this game can start").

lobbyRequirements.help

One line under the control, for the rule a label cannot carry.

Prose only, same rule as the label: it is rendered as text, never as markup.

This is the place for what makes a legal answer in your game — "60 cards, one side, no more than four of any card" — rather than for what the control does. The bounds you declare below are already shown by the picker itself.

lobbyRequirements.appliesTo

Which seated members are asked.

players (the default) asks everyone taking a seat. owner asks only the person who owns the room — the right shape for a scenario or a board that is chosen once for the table rather than brought by each person.

A spectator is never asked anything, under either value, including an owner who is spectating. They are not playing, so they have nothing to bring, and a lobby of spectators can always start.

lobbyRequirements.required

Whether an unanswered requirement blocks Start.

true, the default, means the room owner is refused (lobby-requirements-unmet) until every player it applies to has answered — the honest reading for a game with no legal opening position without one.

false still shows the picker, still puts the chosen deck on the table, and still lists the answer on the roster. It just does not stop anybody. That is what a teaching game or a demo wants, and it means offering the affordance and forcing it are separate decisions.

lobbyRequirements.formatId

deck only — the format id from your data/cardSchema.json that an answer must carry.

Leave it out to accept any deck built for your game.

A deck with a different format is shown in the picker greyed out with the reason, rather than missing from it: a player who cannot find the deck they built has no way to learn why, and hunting for an absent row is a worse experience than being told. The same check runs server-side when the answer is filed, so the disabled row is disclosure and the refusal is the rule.

lobbyRequirements.minCards

deck only — the fewest cards an answer may hold, inclusive.

Omit it for no lower bound. Declaring one bigger than maxCards is refused at parse: the smallest legal deck cannot be bigger than the largest one.

Like formatId, a deck outside the bounds is offered greyed out with the reason ("That deck holds 40 cards; at least 60 are needed") rather than hidden.

⚠ This counts what the deck row records, which is every card in every partition. If your game's minimum is about the main list alone, say so in help — the bound cannot express it.

lobbyRequirements.maxCards

deck only — the most cards an answer may hold, inclusive.

Omit it for no upper bound. See minCards for how a deck outside the bounds is presented and for what exactly is counted.

lobbyRequirements.deckSources

deck only — which pools the lobby picker offers, in tab order.

Defaults to ["mine"]: the player's own decks for your game and nothing else, which is exactly the picker every pack published before this field existed. Widening it is your decision, not ours — a game whose decks are meant to be built rather than borrowed keeps the narrow picker by saying nothing.

Value What it lists
mine The player's own decks for this game, at any visibility. Owner-scoped server-side.
community Every deck other players made public for this game.
database Public lists on an outside provider — requires deckDatabase.

A wider pool is never a wider read. mine is scoped to the caller inside the query and community is a bare "public only" predicate, so no declaration here can reach a private deck belonging to somebody else. This widens what is offered, never what is readable.

database without a deckDatabase is refused at publish, and so is a deckDatabase without database — either one alone is an author who believes something is happening that is not.

lobbyRequirements.deckDatabase

deck only — the outside provider behind the database deck source.

Required when deckSources includes database, and refused when it does not.

"deckSources": ["mine", "community", "database"],
"deckDatabase": {
  "pluginId": "community.swtcg-deckdb",
  "listFunction": "publicDecks",
  "label": "swtcg-deckdb.com"
}

🔴 A name, never a request. The same contract every plugin call follows: requirement → plugin id + function name → plugin manifest → endpoint → origin + path template, all declared data that a reviewer can follow without running anything. The fetch happens server-side under that plugin's own origin allowlist, quota and circuit breaker. There is no URL here.

Both names must already be in your plugins block. A requirement is not a second, looser door onto a plugin you have not declared; publish refuses one that is missing, naming it.

Picking a result imports it. The platform asks the plugin's declared deckImport block for that list, joins it against your game's own card catalogue in the player's browser, and saves the result as a private deck of theirs — so the answer on the roster is an ordinary deck reference, and the player keeps the deck afterwards. A plugin that declares no deckImport cannot be a database source.

modManifestSchema.credits#

Field Type Required Default Min / Max Pattern Rule Description
id string yes 1–64 chars
paths string[] yes 1–2000 items; each 1–300 chars
origin "own-work" | "third-party" | "disclaimed" yes
title string no <= 160 chars
creator string no <= 160 chars
creatorUrl string no <= 500 chars Use a full http(s):// link.
sourceUrl string no <= 500 chars Use a full http(s):// link.
licenseName string no <= 120 chars
licenseUrl string no <= 500 chars Use a full http(s):// link.
notes string no <= 1000 chars
recordedAt string yes ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$
attestation creditAttestationSchema no
source assetSourceRefSchema no
locked true no
derivedFrom assetSourceRefSchema no

Whole-object rules:

  • 1 further cross-field rule (message built at validation time).

credits.id

A stable id for this credit entry, minted when the record is created.

It exists so an entry survives an edit and can be pointed at — by the Credits tab, by a problems list, by anything that needs to name one record among several. It is not meaningful to a reader and nothing resolves it anywhere.

credits.paths

The repo-relative files this record accounts for — textures/board.png, not /textures/board.png and never ../.

One entry covers a whole import, which is usually many files. That is deliberate: the unit of decision is the import, not the file. An author who drops in a 220-card set got it from one place under one licence, and splitting that into 220 identical records would make the Credits tab unreadable and the manifest enormous while saying nothing extra. The editor's import dialog does offer Credit files separately for a mixed drop, which produces one entry per file.

A file may appear in exactly one entry. Two records claiming the same path can disagree, and then "who made this file" has two published answers; the manifest refuses it. When you re-import a file over itself the new record takes the path off the old one, so correcting a credit is just importing again.

The paths here are the ones actually written to your repo, not the names you dropped in. Import hull.fbx and it is converted on the way in, so the record names models/hull.glb — the file that is published is the file that is credited.

credits.origin

Which of the three things this entry says: own-work, third-party or disclaimed.

There is deliberately no fourth value and no empty state. Every file that reaches a mod through the editor has passed through one of these, because the point of the mechanism is that no file enters a mod without a recorded decision.

third-party is the only one with further requirements — a creator, a link, and a licence. disclaimed is the escape hatch for when you cannot supply those, and it is priced: a signature and an acceptance of full responsibility.

credits.title

What the work is called, when it has a name of its own — "Boardgame Pack", "Dungeon Tiles Vol. 2".

Optional, and available on all three origins. It is what the Credits tab leads the line with, so a set of forty files reads as one named thing rather than as forty filenames.

credits.creator

Who made it. Required when origin is third-party, and ignored otherwise.

A name, as they would want to be credited — the person, the studio, the account you got it from. This is the field somebody searching for their own work will find, so write it the way they spell it rather than the way your file is named.

credits.creatorUrl

A link to the creator — their site, their shop, their profile.

A full http(s):// address. Either this or sourceUrl is required for a third-party credit, because a credit nobody can follow credits nobody: a name with no link cannot be verified, cannot be found by the person it names, and does not tell a reader where the work actually lives.

If you truly have no link for either, the honest record is disclaimed, not a bare name.

credits.sourceUrl

A link to where the file itself came from — the asset page, the download, the listing.

Distinct from creatorUrl: one names the person, the other names the thing. Either satisfies the third-party link requirement, and giving both is better than giving one, because the pair is what lets a reader check the licence for themselves.

credits.licenseName

The licence these files are used under, by name — "CC BY 4.0", "CC0 1.0 (Public Domain)", "Purchased / commercial licence".

Either this or licenseUrl is required for a third-party credit. The editor offers a shortlist of common licences and fills the name and URL together, because a free-text field alone produces "CC-BY", "cc by 4.0" and "creative commons attribution 4.0 international" for one licence, none of which links anywhere. Anything not on the list can still be typed.

Naming a licence is not holding one. If the licence does not permit redistribution, or requires terms you have not met, you must not publish the files — recording the name here changes nothing about that.

credits.licenseUrl

A full http(s):// link to the licence text.

Either this or licenseName satisfies the third-party requirement, and the link is the more useful half: it is what lets a reader check the terms rather than take your word for which licence you meant.

credits.notes

Anything the other fields cannot say — an order number, the email that granted permission, a caveat about which files in the set the licence covers.

Available on all three origins, including disclaimed, which is where a "bought in a bundle years ago, receipt lost" belongs. It is published like the rest of the record, so do not put anything in here you would not want read.

credits.recordedAt

When this record was made, as an ISO timestamp.

Set by the editor at the moment you confirm the import. Together with attestation.acceptedAt it is the same instant, because the credit and the signature are one act.

credits.attestation

The signature on this record: which wording you accepted, when, and the name you signed with.

It is what turns a form field into a statement. All three origins carry one — the wording differs (an ownership warranty, an accuracy warranty, or the full indemnity) and termsVersion names which you were shown.

credits.source

The library item this record's files were copied from, when they were copied rather than imported.

It takes one of two shapes: a platform preset ({ "type": "preset", "presetKind", "presetId" }, where presetKind is model, material, texture or deck), or a plugin resource ({ "type": "plugin", "pluginId", "commitSha", "resourcePath" }). A plugin source must name a full 40-character commit sha, the same commit the plugin's resources were scanned and served at.

The platform writes source; you do not author it. It is what makes a locked record checkable: it names where the credit came from.

credits.locked

true when this record is the source's credit, carried verbatim onto files copied from a library item — a platform preset, or a plugin resource, whose credit the plugin manifest was required to carry.

A locked record is not your statement, so it cannot be edited, re-signed, merged into another record or deleted. It leaves only when its files do. For the same reason it validates without an attestation: demanding a signature would demand one nobody can give.

Three rules the schema enforces:

  • locked requires source. A lock naming no source is an unsigned claim nobody can check, and is refused.
  • locked and derivedFrom never appear together.
  • A locked record whose origin is disclaimed still needs an attestation. A disclaimer is a statement only you can make, and no library supplies one: plugin manifests refuse disclaimed resource credits, and no platform preset is disclaimed.

The exemption is record-keeping, not trust: a hand-written manifest can claim a lock it has no right to.

credits.derivedFrom

Where the work came from, on a copy that has been detached from its library source because its bytes were changed locally. Same shape as source.

A detached copy no longer receives updates, and its credit is no longer locked. It is an ordinary, editable record that still says where the files originated. Detaching does not invent a statement you never made, so the record validates without an attestation. Editing it later goes through the normal re-attestation rule like any other change.

The exception is a disclaimed origin: that record always needs an attestation, with or without derivedFrom, because only you can accept responsibility for files with no creator or licence.

Never present together with locked.

modManifestSchema.setupOptions.options#

Field Type Required Default Min / Max Pattern Rule Description
value string yes 1–120 chars
label string yes 1–120 chars

setupOptions.options.value

The string a script will receive when the host picks this choice. 1 to 120 characters.

It is the identity of the choice, so keep it stable across releases: changing a value orphans the answer already stored against the old one, and resolution then falls back to the option's default. Unique within the option.

setupOptions.options.label

What the host reads for this choice. 1 to 120 characters.

Display only — a script never sees it, so it is free to change between releases in a way value is not.

modManifestSchema.lobbyRequirements.deckDatabase#

Field Type Required Default Min / Max Pattern Rule Description
pluginId string yes 1–120 chars
listFunction string yes 1–80 chars
label string no <= 60 chars

lobbyRequirements.deckDatabase.pluginId

The registered plugin holding the deck database.

Must be a plugin your manifest already lists in plugins. Publish refuses an id that is not there, because the plugins block is meant to be the whole of your pack's external reach — readable without running the script.

lobbyRequirements.deckDatabase.listFunction

The exposedApi function that lists the provider's public decks.

Must be one of the functions named for that plugin in your plugins block; publish refuses one that is not.

It has to take no required parameters and return an array of rows carrying at least an id and a name. Rows may also carry side, format, pool, card_count, owner_username and played_by, which the picker shows when they are there and ignores when they are not — a provider returning something else is normal, not a fault.

It is called once when the tab opens, and searched in the browser. The providers this exists for publish their entire public list as one document and have no search endpoint at all; turning keystrokes into provider requests would be the wrong shape and would spend that plugin's shared daily budget on typing.

lobbyRequirements.deckDatabase.label

What the picker calls the database tab. Plain text, rendered as text.

Omit it and the tab reads "Deck database". Naming the site — "swtcg-deckdb.com" — usually reads better, because a player recognises where their lists actually live.

modManifestSchema.credits.attestation#

Field Type Required Default Min / Max Pattern Rule Description
termsVersion string yes 1–40 chars
acceptedAt string yes ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$
signedName string yes 1–120 chars

credits.attestation.termsVersion

Which version of the attestation wording was on screen when you signed.

This is the field that makes a stored signature mean something years later: it names the text you actually accepted. The wording is versioned and never edited in place, so a record written under an older version still resolves to what it said at the time.

credits.attestation.acceptedAt

When the attestation was accepted, as an ISO timestamp. The same instant as recordedAt — the credit and the signature are one act.

credits.attestation.signedName

The name you typed to sign the record.

Typed by hand on purpose. Your account is already on the publish, so this adds no identity that was not already there — what it adds is the deliberateness of having typed your own name, which is the difference between a claim and a checkbox.

modAssetRefSchema#

Exported from @diceytable/shared as modAssetRefSchema. 2 fields across 1 table.

Field Type Required Default Min / Max Pattern Rule Description
path string yes 1–180 chars
sha256 string no ^[a-f0-9]{64}$

path#

The repo-relative path that identifies the file — the same string that appears in assets, in your repo, and as the key the local play cache stores the bytes under. It is what ties a catalog entry to a real file.

Because it is also the download target it has to match the published path exactly, case included. A path that isn't in the repo at the published commit is recorded as a failed pull, its bytes never reach the cache, and anything referencing it falls through to a GitHub URL that 404s in the same way.

sha256#

The integrity check for one file. pullModTree (apps/web/src/publish/githubPull.ts) hashes each downloaded blob and compares it with this value; a mismatch aborts that file, so bytes that failed verification never enter the play cache. This is defense in depth, not the acceptance gate — the server-side scan is that.

Omit it and the file downloads and caches with no verification, which is why older manifests keep working. Leave a stale one behind and the file fails verification on every session, falls back to loading straight from raw.githubusercontent.com, and quietly costs you the cache instead of the asset. Regenerate the hash whenever you republish changed bytes.

modScreenshotSchema#

Exported from @diceytable/shared as modScreenshotSchema. 10 fields across 3 tables.

Field Type Required Default Min / Max Pattern Rule Description
path string no 1–180 chars
crop cropRectSchema no Crop rectangle must stay within the image bounds (x+w and y+h ≤ 1).
video modVideoSchema no

Whole-object rules:

  • A gallery entry needs either an image path or a video.

path#

The repo path of the published screenshot — the 16:9 WebP the media pipeline produced, not the picture you uploaded. Nothing in the manifest points at your original, so keep it somewhere you can find it again.

The editor names these files by slot (media/screenshot-1.webp and upward) and overwrites in place, so replacing or re-cropping a screenshot reuses its path instead of piling spare images into the repo.

crop#

A record of how the published image was framed, in fractions of the source, so the editor can reopen the crop tool on the same rectangle instead of making you upload the picture again. Nothing reads it at display time — the file at path is already cropped.

The reach of that record has a limit worth knowing before you lean on it. While the authoring session that uploaded the image is still open, a re-crop starts from your original with this rectangle as its frame. Once that session is gone the original is gone with it, and a later re-crop works from the published 16:9 derivative — so it can tighten the frame and never recover what the first crop cut away. Keep originals outside the mod if you expect to reframe them later.

video#

Links a YouTube video into the gallery entry. With no path beside it the entry is the video; with one, that image is the video's cover.

A video is the one gallery item whose bytes are not yours — nothing is published for it and nothing is scanned. It also costs a visitor nothing until they want it: the page draws the cover and loads YouTube's player only when somebody presses play.

Add one from Mod Details ▸ Screenshots & videos ▸ Add a video, and give it a cover with the image button on its tile. Without a cover, YouTube's own still is used — upload one only when you want a particular frame to introduce the video.

A game may also publish a video as a rulebook (rules[] with kind: "video"), which is the right place for a how-to-play. Use the gallery for the trailer and the rulebook for the tutorial.

modScreenshotSchema.crop#

Field Type Required Default Min / Max Pattern Rule Description
x number yes >= 0, <= 1
y number yes >= 0, <= 1
w number yes > 0, <= 1
h number yes > 0, <= 1

Whole-object rules:

  • Crop rectangle must stay within the image bounds (x+w and y+h ≤ 1).

crop.x

The left edge of the crop window, as a fraction of the source image's width. 0 starts at the left edge; 0.25 skips the first quarter of the picture.

Fractions rather than pixels are what let the rectangle survive re-encoding: the same four numbers describe the same framing on the original upload and on a resized copy of it, which is the whole reason a crop can be reopened at all.

crop.y

The top edge of the crop window, as a fraction of the source image's height, measured downward from the top. 0 starts at the top of the picture, and a larger y slides the window toward the bottom.

This is the axis to double-check when you write a rectangle by hand: image space puts the origin at the top-left and counts down, the opposite of the Y-up convention the table's world coordinates use.

crop.w

How much of the source's width the window keeps, measured rightward from x. 1 keeps the full width.

What is easy to miss is that w and h are fractions of two different dimensions, so equal values do not describe a square window unless the source itself is square. The editor's crop tool sidesteps the problem by locking the window to the output aspect as you drag it; a rectangle typed into the manifest by hand carries no such guarantee.

crop.h

How much of the source's height the window keeps, measured downward from y.

The output aspect is fixed — square for a cover, 16:9 for a screenshot — and the pipeline cover-fits your window into it, trimming the long axis symmetrically rather than letterboxing or stretching (resolveSourceDrawRect, apps/web/src/ui/editor/mediaPipeline.ts). A window that isn't already at the output aspect therefore loses part of what you asked for on one axis. Frame at the target aspect when the exact edges matter.

modScreenshotSchema.video#

Field Type Required Default Min / Max Pattern Rule Description
provider "youtube" yes
id string yes ^[A-Za-z0-9_-]{11}$
title string no 1–120 chars

video.provider

Which service hosts the video. "youtube" is the only value.

It is stored explicitly rather than inferred so that a second provider, if one is ever added, is a change you can see in your manifest instead of a link that silently starts behaving differently.

video.id

The YouTube video id — the eleven characters after watch?v=, and not a URL.

Paste any normal YouTube link into the editor (a watch page, a youtu.be link, a Short, or the id on its own) and it extracts the id for you. The manifest stores only the id because that is the narrowest thing that can be turned back into a player: a reviewer reading your manifest can see exactly which video plays and nothing else can be smuggled in beside it.

Hand-writing one? It must match [A-Za-z0-9_-]{11} exactly; anything longer or shorter is refused at publish.

video.title

An optional label, shown over the cover and used as the video's accessible name.

Worth setting when a pack has more than one video, so the play buttons read as "Play video: Setup in two minutes" rather than "Play video: Video" to anyone using a screen reader. Up to 120 characters.