Asset Delivery and the CDN
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, 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.
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.comat 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:
{ "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 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. 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. |
| 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:
"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 platform 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. |
| 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.
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.
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:
"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 — nothing in it is
fetched by a browser.
Caching and cache busting#
| Ref shape | Cache-Control |
|---|---|
Commit sha (12e39c5f...) |
public, max-age=31536000, immutable — a year. The bytes can never change. |
Branch (main, refs/heads/release) |
public, max-age=300, stale-while-revalidate=86400 |
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 atraw.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.
- 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. 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, a third-party repo, or one of the deliberate
freshness reads listed above.
