Dicey Table

Publishing

Asset Delivery and the CDN

How mod files and declared plugin resources reach browsers through cdn.diceytable.com, the rules the edge enforces, and what stays on GitHub.

On this page10 sections

Your mod's files live in your GitHub repo and never move. But the road they take to a player's browser is not always the same one, and which road they take is decided entirely by how you reference them. This page is the rule set.

Two roads#

At play time every peer independently pulls your published mod and caches it locally (see Registration & Multiplayer Play). Anything a peer does not have locally — a file the manifest never declared (including one you set to load on demand), a cache that was evicted, a device that just joined — is fetched over the network instead. That fetch goes one of two ways:

  • raw.githubusercontent.com — the public GitHub URL. Always works. It is the fallback the platform has always used and it is not going away.
  • cdn.diceytable.com — a proxy in front of exactly that content, on the same edge network that serves the rest of the app.

Nothing is copied or re-hosted. The CDN fetches the same bytes from the same GitHub URL; it just sits closer to the player and remembers the answer.

The pull itself takes the CDN road too. The play-mode pre-pull of your published tree, pack documents and card catalogues are fetched through the edge. If the edge answers with anything but success (a pack registered seconds ago, before the allowlist refreshes, or an edge outage), that file is fetched again from raw.githubusercontent.com. A slow load is better than a missing file.

Why the CDN is worth having#

Measured on a live table serving a large card game: 8.8 MB of card art arrived over HTTP/2 from a Fastly-fronted origin advertising no alt-svc, while every first-party byte on the same page came over HTTP/3 from an edge a few milliseconds away. Same page, same moment, two very different roads.

Routing mod assets through the CDN buys three things:

  • Edge termination and HTTP/3. The connection is set up against a nearby point of presence rather than a distant origin — the difference is largest on exactly the connections that need it most: mobile, high-latency, lossy.
  • A real cache. A commit-pinned file is cached for a year. The second player to join a table does not re-fetch what the first one already pulled through that edge.
  • Fewer trips to GitHub. Cache hits never reach raw.githubusercontent.com at all, which keeps you further away from GitHub's own limits on a busy table.

You do not opt in — you can only opt out#

There is no CDN URL to write, no manifest key, and no setting. Reference a file by its repo-relative path and the platform routes it for you:

JSON
{ "model": "models/pawn.glb", "texture": "assets/textures/pawn-basecolor.png" }

The resolver turns a repo-relative path into raw.githubusercontent.com/{owner}/{repo}/{ref}/... and then swaps the origin for the CDN. Same path, same query string, same everything — a pure origin swap.

The same swap applies to your pack's page media (the cover image, screenshots and rulebook images, wherever the site shows them, including the home page) and to repo-relative card art in a catalogue. File types the edge refuses stay on GitHub. Scripts, and .md files outside rules/, are never rewritten, so a fetch that works from GitHub is never sent where it would 404.

The one way to lose it: writing an absolute URL yourself.

A value that is already a full https://raw.githubusercontent.com/... URL is treated as "already loadable" and handed straight to the browser. It never reaches the rewrite, so it never reaches the CDN — and because that is indistinguishable from any other absolute URL, nothing warns you.

This matters most for card catalogues, where it is tempting to store a complete image URL per row. If those rows name your own repo, store the repo-relative path instead. If they name someone else's repo, declare it and the platform will rewrite those absolute URLs for you.

What the edge enforces#

Every request is checked before a single byte is fetched from GitHub.

Rule Detail
Registered or declared repos only The coordinate must appear on the platform's published allowlist, which is built from the repos of registered packs plus the outside repos those packs declared. A plugin's own repository contributes only its declared resource files, path by path. Everything else is refused.
Exact ref match A mod registered at commit abc123 does not authorise its own main, and vice versa. Those are different bytes, which is the whole reason a registration pins a commit. The full commit sha of every published version of a pack is admitted too, so a room pinned to an older version is still served from the edge.
Extension allowlist The same 18 extensions a mod may publish — see Assets. Anything else is refused before an upstream request is made, so a scan for /.env costs nothing.
GET and HEAD only Plus OPTIONS for preflight. Anything else is 405.
64 MB per file A larger file is refused rather than streamed. Mod assets are far below this.
Content-Type is decided here Re-asserted from the extension, never inherited — GitHub serves much of this as text/plain. Sent with X-Content-Type-Options: nosniff.
Responses are inert Content-Security-Policy: default-src 'none'; sandbox, Referrer-Policy: no-referrer, Cross-Origin-Resource-Policy: cross-origin, and Access-Control-Allow-Origin: * so the table runtime can fetch bytes to compose sprite sheets.

A refusal is a 404, not a 403 — whether a particular repo is registered is not a fact this endpoint confirms to anonymous callers. The response body names the reason in plain text.

Why the allowlist exists#

An unrestricted cdn.diceytable.com/{owner}/{repo}/{ref}/{path} would be a free, anonymous CDN for arbitrary GitHub content, served from our domain and paid for by our bandwidth. The proxy is defensible only because every coordinate on it is one a person can trace to a pack: either the pack's own scanned repository, or one that pack explicitly named in its manifest. That is the design, not a hardening pass on top of it — so "please allow my repo" is answered by registering a pack from it or declaring it from one, never by an exception.

Art in somebody else’s repository#

A card catalogue routinely reads art from a repository the pack does not own — community-maintained set images, say. Those rows carry absolute raw.githubusercontent.com URLs, so they take neither of the roads above, and the edge would refuse them because the repository behind them is not one this platform registered.

Declare it and both problems go away:

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

The same block works in a mod manifest and in a plugin manifest. What it does:

  • puts that coordinate on the edge’s allowlist, and
  • lets the client rewrite absolute GitHub URLs naming it onto the CDN — including the ones inside your catalogue, which nothing else could reach.

It grants no read a 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 serve from our domain, which is why it carries four rules:

Rule Detail
At most four repos per manifest The cap is the abuse control. A pack pulling art from five different strangers is a pack a person should look at.
Declared, never inferred The edge will not read your catalogue and work the hosts out for itself. An entry is a line in a public, commit-pinned manifest that a reviewer reads once — which is why publishing writes the line for you rather than the edge guessing it.
Shown on your pack’s page Under Serves art from, with the ref, so a player can see whose repositories a table is pulling from.
The ref matches exactly Declaring main does not authorise a sha and a sha does not authorise main. Declare the ref your URLs actually address.

A branch is allowed, a commit is better. Registration emits an asset-repo-unpinned warning for a branch rather than refusing it, because you usually cannot pin somebody else’s repository — the day they publish a new set, every card in it renders blank until you re-publish your pack just to move the pin. The warning is telling you about caching: the edge holds a commit-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 commit whenever the repository is one you control — and pin the full 40-character sha: an abbreviated one also draws the warning and is cached like a branch, because a branch can carry that name.

Written for you at publish#

When you publish from the editor, the platform reads every card catalogue (*.cards.json) your manifest lists under assets and adds to assetRepos each GitHub repository and ref their art URLs address that the manifest does not already declare. The coordinate is copied from the URLs exactly as written, ref included. The entry lands in the committed diceytable.mod.json, so it is the same reviewable declaration it would be if you had typed it; the edge still reads only the manifest.

  • It only adds. Entries you wrote are kept exactly as written, including one that no URL uses any more (a master entry left over after your art moved to refs/heads/release, say). Remove those yourself; they still count toward the cap of four.
  • It stops at the cap. Coordinates that would take the list past four, or that are not a valid declaration, are left out, and the catalogue-art-undeclared warning below names them.
  • It is stable. New entries are appended in sorted order, so republishing an unchanged pack produces the same file.

Declare by hand anything else: art your pack reaches other than through a card catalogue, and every outside repository of a pack you publish without the editor.

A mismatch is a warning, not a failure — read it. If your card catalogue’s art points at a repository, or a ref, that assetRepos does not cover, registration emits a catalogue-art-undeclared warning naming what it found. Nothing breaks when you ignore it and that is exactly the problem: an undeclared URL is passed through untouched, so every card still renders and the only symptom is that each player downloads your card art from someone else’s repository on every cold load. The commonest cause is a ref that is nearly right — declaring master while the URLs say refs/heads/release routes nothing at all, because the ref is compared literally. Publishing from the editor fixes that case for you. If you still see the warning, the pack was published some other way, it already declares four repositories, or the catalogue could not be read at publish time (larger than 16 MB, or GitHub did not answer) — republish, or declare the entry by hand.

Declaring dies with the pack. A blocked pack’s declarations stop being served with it, and a pack switched off the edge takes its declared repos off with it too.

From a plugin#

A plugin declares the same block, and additionally has to name the CDN as the art origin:

JSON
"origins": ["api.example.com", "cdn.diceytable.com"],
"assetRepos": [{ "owner": "SWTCG", "repo": "SWTCG-LACKEY", "ref": "refs/heads/release" }],
"cardMapping": {
  "artUrl": {
    "field": "art",
    "origin": "cdn.diceytable.com",
    "path": "/SWTCG/SWTCG-LACKEY/refs/heads/release/sets/{set}/{frag}.jpg",
    "params": { "set": "set", "frag": "card_id" }
  }
}

The first three segments of that path must be literal, and they must name a repo in assetRepos. Both are checked when the plugin registers, and both are errors rather than warnings: they are decidable from the manifest alone, and getting either wrong means every card 404s with nothing to fall back to. A plugin’s own repository is never on the allowlist as a repository: the only files from it a browser may fetch through the edge are the ones it declares under resources, described next.

Plugin resources: exact paths at a pinned commit#

A plugin may declare resource files — models, textures, materials, deck definitions and their thumbnails — under resources in diceytable.plugin.json (Writing a plugin). Those files, and only those, are admitted from the plugin's own repository, under rules narrower than a mod's:

Rule Detail
Exact paths, never the repo Each declared path and thumbnail is listed individually. The request path is compared byte-for-byte as it arrived — still percent-encoded, case-sensitive — so an undeclared sibling in the same folder at the same commit is refused, and a re-cased or re-encoded spelling of a declared path does not match.
The registered commit sha, never a branch The grant names the plugin's registered commit. A path grant at a branch would authorise whatever that branch points at next, which nobody scanned, so the edge drops any grant whose ref is not a commit sha. Being commit-addressed, these files get the year-long immutable cache.
Scanned, not merely declared A path is listed only if the scan fetched it at that commit and it passed every check (size, content type, magic bytes, and the reference rules for models, materials and decks).
The bytes are the commit's bytes During the scan, each file's git blob sha must match the one GitHub's git data API lists for that path in the registered commit. A file that differs, or that the commit does not contain, is refused. The grant carries that sha. The edge hashes what GitHub returns, serves it only on an exact match, and caches only a copy that matched. Anything else is refused with a 502 that is never cached. Files are limited to 50 MB, the same limit the scan applies.
Compatible plugins only A plugin whose scan came back incompatible, or that an administrator blocked, contributes no paths at all.
Registry-wide, not per room The edge cannot know which project or room a request belongs to, and these files are public, sha-pinned and reviewed. Whether a resource is used is decided in the editor — a registered plugin is inert until a project lists it.

Path grants travel as a separate list on the allowlist rather than as a field on a repository entry. An edge older than this contract therefore ignores them and refuses the files — it fails closed — instead of reading a path grant as a grant for the whole repository, or serving a file it cannot check.

The content check exists because a commit sha in a raw URL is still a name that GitHub resolves. The scan matches each file to the commit by object id, and the edge pins the bytes to that match. So the file a player receives is the file in the registered commit, however that name resolves. A pack's own files and the repositories in assetRepos have no per-file record, so they are served as fetched, under the ref rules above.

These files are for the editor and renderer. They are not a network path for a mod script, which still has none.

Caching and cache busting#

Ref Cache-Control
A full 40-character commit sha that GitHub confirmed is a commit (12e39c5fba609d948187326a5bca50609e328aaa) public, max-age=31536000, immutable — a year. The bytes can never change.
Anything else: a branch (main, refs/heads/release), a tag, an abbreviated sha (12e39c5), or a 40-character ref that has not been confirmed public, max-age=300, stale-while-revalidate=86400

A sha's shape is not enough for the long cache. GitHub resolves the ref in a raw URL against branch and tag names as well as commits, so cafe1234 — or 40 hex characters — can be a branch that moves. The platform asks GitHub's commits API whether each full sha on the allowlist is really a commit in that repository, and only a confirmed one is cached for a year. This applies to your pack's registered sha and to the refs in assetRepos alike. Your pack's own sha is confirmed during registration, so it gets the long cache as soon as the allowlist refreshes. Other shas, such as those in assetRepos and any sha after a server restart, are confirmed in the background, and only for packs that scanned compatible. Until that happens they are served with the short cache; nothing breaks while it does.

A branch-addressed URL is byte-identical before and after you publish, so a long cache would serve your old art indefinitely. The platform appends ?v=<contentVersion> to branch-addressed asset URLs, and the query string is part of the cache key — a republish is therefore a new object, not a stale hit. Refusals are no-store, so an asset that 404s only because the allowlist had not refreshed yet does not stay broken.

What the CDN will not serve#

Each of these falls back to GitHub, is refused, or never enters the rewrite in the first place. None of them is a bug.

  • A repo that is neither a registered pack nor declared by one. An undeclared third-party art host is refused with a 404; leave those URLs pointing at raw.githubusercontent.com, or declare the repo.
  • A blocked pack, and everything it declared. Taken down means taken down, on every road.
  • A ref other than the registered one or a published version's full commit sha.
  • Any file in a plugin's repository that is not a declared, scanned resource — including the plugin's manifest, its table script, and a declared file whose scan failed.
  • An extension outside the 18.
  • Anything whose job is to observe GitHub's current state. The pack liveness probe, fresh verification and the publish scanner all deliberately read GitHub directly with Cache-Control: no-cache. Putting a cache in front of "is this repo still there, right now" turns a verified-fresh answer into a verified-stale one, and a scan of a cached copy is a scan of something other than what will run.
  • Local development and PR previews. These run with no CDN origin configured, which is a supported mode: every URL passes straight through to GitHub. A deployment sets the origin in two places, VITE_MOD_ASSET_CDN for the web client and MOD_ASSET_CDN_ORIGIN for the server-rendered game pages, and the two must name the same origin. It is also why a preview deployment testing an unregistered repo still works.

A mod can also be switched off the edge by an administrator. That is not a refusal — the edge answers a 302 redirect to the GitHub URL, cached as aggressively as the asset itself, so the pack stays fully playable and the bytes simply travel GitHub → browser without touching our bandwidth.

Rate limits#

There is no per-client rate limit at the edge, and no quota you can exhaust. The ceilings that do exist are the ones in the table above: the allowlist, the extension list, the 64 MB per-file cap, and the method restriction. Beyond those, requests are subject to the CDN platform's own generic protections, and — on a cache miss only — to GitHub's ordinary limits on raw.githubusercontent.com. Cache hits never reach GitHub, which is one of the reasons the CDN helps a busy table rather than merely a distant one.

If the allowlist service is unreachable, the edge keeps honouring the last copy it successfully fetched rather than failing closed: a registry blip must not black out every custom texture on every table. It fails closed only when it has never held a copy at all.

Checking which road your assets took#

Open the browser console on a table. The app reports its asset routing once at startup:

[mod-assets] serving from CDN https://cdn.diceytable.com

or

[mod-assets] no VITE_MOD_ASSET_CDN in this build; serving from raw.githubusercontent.com

Then open the network panel and filter by domain. Requests still going to raw.githubusercontent.com while the first line says the CDN is active are the interesting ones — each will be an absolute URL written by hand, an undeclared third-party repo, a script file, one of the deliberate freshness reads listed above, or a fallback after the edge refused a file. A fallback is logged as [mod-assets] CDN answered <status> for <url>; falling back to GitHub.

A file that is simply not in your repository at that commit is not a fallback: the edge asked GitHub already, so the app takes that 404 as the answer and asks nobody else. It shows up as one 404 on cdn.diceytable.com, with no warning and no second request.

Did this page answer your question?