# Offline Play

> How DiceyTable installs as an app, which two caches have to be warm before a table boots with no network, and what makes a published mod solo-offline-capable.

- Source: https://diceytable.com/docs/mods/publishing/offline-play
- Last verified: 2026-09-12

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](https://diceytable.com/docs/mods/publishing/registration-and-play) and in
[Asset Delivery and the CDN](https://diceytable.com/docs/mods/publishing/asset-delivery), 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 calls** | No `plugins` entry naming a function, and no `plugin-call` capability. See below. A resource-only entry (`"functions": []`) does not count: nothing reaches the plugin during play. |
| **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. |

A blank table — no mod at all — has no mod assets to cache and no scan status to check, so it
passes the check as soon as storage works and a saved session exists. (It plays in the default
room and table packs, Modern Room and Square Table; the check does not look at those packs' files.)

### 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](https://diceytable.com/docs/plugins/calling-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](https://diceytable.com/docs/mods/publishing/offline-play#what-this-means-for-you-as-an-author).

## See also

- [Registration & Multiplayer Play](https://diceytable.com/docs/mods/publishing/registration-and-play)
- [Asset Delivery and the CDN](https://diceytable.com/docs/mods/publishing/asset-delivery)
- [Calling a Plugin from a Mod](https://diceytable.com/docs/plugins/calling-from-a-mod)
