Offline Play
DiceyTable installs as an app, and an installed copy can open a table with the network switched off. Nothing about that is something you turn on: there is no manifest key, no capability, no scanner rule and no scripting API for it. Whether a given mod plays offline on a given device is inferred at the moment the app asks, from what that device has already cached and from what your manifest already declares.
This page is the rule set, and the short version of it is: a mod plays offline once somebody has actually played it online at least once, on that device, and it does not depend on a plugin.
Installing#
The app ships a service worker that precaches a deliberately small set of things — the app
shell, the branded offline page, the hashed CSS and fonts, the icon font, the small PWA icon
and the vendored wasm decoders (ammo/, draco/, basis/). That is enough for the browser
to treat DiceyTable as installable, and enough to paint a real, branded page with no network.
- Chromium desktop, Edge and Android surface a native install prompt; the site header shows an Install app button once the browser reports the app is installable.
- iOS Safari has no install prompt, so the header shows a one-off dismissible card pointing at Share → Add to Home Screen. Offline play works both in a Safari tab and in the installed app.
The service worker registers only in a production build, over a secure context. It is
deliberately never registered under the dev server, where it would cache an unhashed module
graph and produce stale-module bugs — so none of what follows is reproducible from dev. Use
a real build and a preview server.
Two caches, and both have to be warm#
Offline play depends on two entirely independent caches. Neither one fills in for the other, and a mod is playable offline only when both are warm for it.
1. The app's own code#
The /table runtime is code-split into a very large number of content-hashed chunks, and
those chunks are not precached — precaching the whole code-split graph is a measured
bundle-size regression, and the same splitting that keeps the rest of the site fast is what
makes precaching it a bad trade. Instead, same-origin /assets/*.js, .mjs and .css are
cached at runtime as the app fetches them, and served cache-first afterwards. Content-hashed
names make cache-first safe: a new build is a new URL, never new bytes at an old one.
The practical consequence is one sentence long, and it is the one to tell your players:
Open the table online once. After that it boots offline.
A browser that has only ever seen the marketing pages has never fetched a single table chunk, so it has nothing to boot from. That case is handled honestly rather than silently: before serving the cached shell to an offline visitor, the worker checks that the shell's entry chunks are actually in a cache, and serves the branded offline page instead of a shell that would white-screen.
2. The mod's assets#
A published mod's models and textures are pulled from its GitHub repo into a local cache the
first time a peer plays it — the browser's origin-private file system where available,
IndexedDB otherwise — scoped to that mod's owner/repo/<content version>. Once those files
are primed, a repo-relative asset path resolves to a local blob and is never re-fetched. That
is the same pull cache described in
Registration & Multiplayer Play and in
Asset Delivery and the CDN, and offline play is that
story with the fallback road removed: with no network there is no raw.githubusercontent.com
and no CDN to fall back to, so anything not already cached is simply not there.
Mod assets never travel through the service worker's runtime cache. It is scoped to
same-origin /assets/ and nothing else, on purpose — the local pull cache is the single
source of truth for a mod's bytes, and a second, weaker copy of them under a different
eviction policy could only ever disagree with it.
What makes a mod offline-capable#
The app computes this per device, per mod, every time it needs to. All of the following must hold:
| Condition | Why |
|---|---|
| Local storage is usable | A browser that will not let DiceyTable store data on the device has no cache and no saved session, so there is nothing to open. |
| The mod's last scan came back compatible | A blocked, pending or incompatible pack does not load online either. Offering it offline would be a worse lie, not a kindness. |
| It declares no plugin use | No plugins entry in the manifest, and no plugin-call capability. See below. |
| Every declared asset is cached | The check reads assetManifest when the manifest has one and falls back to the flat assets list otherwise — the same precedence the pull cache itself uses. A path the manifest never declared is a path nothing pre-fetched. |
| There is a saved local session for it | Offline play resumes a session that already exists on the device. It does not build a table from nothing. |
The built-in table — no mod at all — has no assets to cache and no scan status to check, so it is offline-capable as soon as storage works and a saved session exists.
Why a plugin-backed pack is excluded#
A plugin call is not a request your mod makes. Your mod names a declared function on an installed plugin, and the platform performs the call server-side, under that plugin's own origin allowlist, credentials and quota — see Calling a Plugin from a Mod. With no network there is no server to perform it, so the call cannot happen and cannot be faked.
That is why a plugin dependency disqualifies a pack outright instead of degrading it. A pack
whose card data or art arrives through a plugin would open offline into a table it cannot
populate: present, launchable, and unable to deal its own cards. Refusing to offer it is the
honest answer. The two reference plugins are the concrete cases — a pack backed by
scryfall-mtg or by swtcg-deckdb is correctly excluded from offline play, and that is not a
bug report.
Both halves of the condition are checked independently. A manifest is supposed to declare
plugin-call whenever it lists plugins, but a manifest published before that rule, or
hand-edited since, can carry one without the other — and either alone excludes the pack.
Declaring read-decks is not a blocker. It reads the signed-in person's library from the
server, but a mod may declare it and never call it, and when it is called offline it fails
exactly the way it fails on a flaky connection.
What this means for you as an author#
Two pieces of actionable advice, and they are the only two:
- Declare every asset you use. The offline check knows only about files your manifest names. An asset your scripts reach for at runtime but that the manifest never listed will not be in the local cache, and the pack will not be offered offline — with no error anywhere to explain why.
- Do not make a plugin a hard dependency of core play. If a plugin enriches a table but the game is playable without it, ship the baseline content in the repo and let the plugin add to it. If the plugin is the game — a live card database, a deck importer — then the pack is an online pack, and that is a legitimate thing to be.
Offline play is solo#
An offline session is single player. There are no peers and no signaling, and the local user is trivially the authority: the same host-authoritative path runs, against a session with nobody else in it.
One consequence is worth stating because it surprises people who have read about hidden information: no redaction is involved. Per-peer snapshot redaction exists to hide face-down cards from other peers, and with zero peers there is nobody to hide them from. A solo offline table holding a deck and a face-down hand behaves normally, and sidesteps the redaction cost entirely.
Coming back online#
When connectivity returns, the offline session is uploaded as a named save to the room it started from. It is an upload and nothing else: it does not rejoin, does not open signaling, does not claim host, and cannot collide with a live table somebody else is hosting in that room. Authority over a shared table is granted only by a person deliberately starting or joining one from the saves list.
A session that never had a room is not uploaded at all — it stays a purely local save the user loads when they want it. That is a normal outcome, not a failure.
The upload is queued and drained on the browser's online event, and again on the next app
open. That pairing is the whole mechanism, chosen because it works everywhere: iOS has no
Background Sync at all, so Background Sync is only ever an accelerator on Chromium and never
the path this depends on. The queue is idempotent by item id, and an item is deleted only after
its upload succeeds, so a crash mid-drain re-drains cleanly rather than losing the session.
There is no offline API#
Nothing on this page is addressable from a script.
- There is no offline-related symbol in the table scripting API or in the mod API.
- There is no manifest field that asserts, requests or opts out of offline capability.
- There is no scanner rule about it, and registration neither checks nor reports it.
Offline capability is a property of a device at a moment in time, not a property of your pack, which is exactly why it is inferred rather than declared. The only levers you have are the two under What this means for you as an author.
