Skip to Content
Local CacheVerse8 Local Cache

Verse8 Local Cache

An SDK (@verse8/local-cache) that lets your game cache regenerable data on the device. The Verse8 shell stores the bytes on your behalf, so the cache survives on disk even inside a cross-site iframe on Safari/iOS.

This is a device-local, best-effort cache.

  • It is never synced across devices, browsers, or platforms (web / app / OneStore). Logging in on another device gives an empty cache.
  • Any entry can be evicted at any time (quota pressure, LRU eviction, the user clearing site data, browser purge). get() returning null is a normal cache miss, not an error.
  • Never store player progress or inventory here. Use the game server (@agent8/gameserver). This cache is for regenerable derived data only: seeded terrain, decoded assets, precomputed tables.

Why

Verse8 games run as a cross-site iframe ({id}.verse8.games inside verse8.io). WebKit (Safari, and every iOS browser) treats third-party IndexedDB and localStorage as ephemeral — writes succeed, but nothing reaches disk and it all disappears when the page is discarded. That makes in-frame storage useless as a “restore after discard” cache.

@verse8/local-cache forwards your requests to the shell (verse8.io) over a MessageChannel, and the shell stores them in its own first-party IndexedDB, which is not subject to the ephemeral rule. Where no shell is present, it falls back to in-frame storage.

Install

pnpm add @verse8/local-cache # npm i @verse8/local-cache / yarn add @verse8/local-cache

Or via CDN — the bundle exposes a single Verse8LocalCache global:

<script src="https://unpkg.com/@verse8/local-cache@latest/dist/index.global.js"></script> <!-- window.Verse8LocalCache -->

Quick start

import { Verse8LocalCache } from "@verse8/local-cache"; async function loadTerrain(seed: number): Promise<ArrayBuffer> { const key = `terrain:${seed}`; const cached = await Verse8LocalCache.get(key); if (cached) return cached; // hit const fresh = generateTerrain(seed); // miss → regenerate (always possible) const r = await Verse8LocalCache.set(key, fresh); if (!r.ok) console.debug("terrain not cached:", r.reason); // fine — still playable return fresh; }

No init() call is required — the first call bootstraps. Call Verse8LocalCache.init({ ... }) only to pass options (see below).

API

All methods are async. Failures are values, never exceptions (except programming errors such as a non-string key).

init(opts?: { parentOrigin?: string; // auto-resolved; see "How the host is found" handshakeTimeoutMs?: number; // default 1500 limits?: Partial<Limits>; // in-frame fallback only dbName?: string; // in-frame fallback only debug?: boolean; silent?: boolean; // suppress the one-time "host unsupported" warning }): void ready(): Promise<'host' | 'local' | 'memory'> // resolves once the backend is decided getBackend(): 'host' | 'local' | 'memory' | null get(key): Promise<ArrayBuffer | null> // null = miss (normal path) set(key, value: ArrayBuffer | ArrayBufferView, opts?: { transfer?: boolean }): Promise< { ok: true } | { ok: false; reason: 'QUOTA_EXCEEDED' | 'TOO_LARGE' | 'UNAVAILABLE' }> delete(key): Promise<void> keys(prefix?): Promise<string[]> clear(): Promise<void> quota(): Promise<{ usedBytes: number; limitBytes: number }> getJSON<T>(key): Promise<T | null> // UTF-8 JSON convenience setJSON(key, value): Promise<SetResult>
MethodDescription
init(opts?)Optional. Call only to pass options
ready()Resolves once the backend (host / local / memory) is decided
getBackend()Backend in use, or null before ready() settles
get(key)ArrayBuffer or null on miss. A miss is the normal path — regenerate and set
set(key, value, opts?)Store bytes. By default the value is copied, so your buffer stays usable. Pass { transfer: true } with a raw ArrayBuffer you no longer need to hand it over zero-copy — it becomes detached (byteLength 0). Views such as Uint8Array are always copied
delete(key)Remove an entry
keys(prefix?)List keys, optionally by prefix
clear()Empty the whole namespace
quota(){ usedBytes, limitBytes }
getJSON(key) / setJSON(key, value)UTF-8 JSON convenience over get/set

set failure reasons:

reasonMeaningWhat to do
QUOTA_EXCEEDEDThe namespace would exceed its byte limitFree space or store less
TOO_LARGEA single value (or key) exceeds the hard per-item limitSplit it or skip caching
UNAVAILABLEBackend unreachable / threwTreat like a miss and move on

Backends — what ready() tells you

BackendWhenDurability
hostGame is framed by a Verse8 shell that runs @verse8/local-cache/parentDurable (shell’s first-party IndexedDB)
localGame is the top-level document (Verse8 mobile app WebView, standalone) — or the host is silent/declinesDurable when top-level. Ephemeral on Safari/iOS when cross-site framed
memoryIndexedDB unusable (private mode, storage blocked)Lost on reload

The SDK prints one console line describing its decision:

  • top-level → console.info(...first-party context...)
  • host silent / declined / untrusted parent → console.warn(...host does not support the local cache; falling back to in-frame IndexedDB. On Safari/iOS this storage is EPHEMERAL...)
  • no IndexedDB → console.warn(...in-memory...)

Pass init({ silent: true }) to suppress it. Either way, getBackend() lets you branch (e.g. skip caching multi-MB blobs on memory).

How the host is found

No v8-inject.js change or extra setup is needed. init({ parentOrigin }) wins. Otherwise, in order: window.verse8.parentOrigin (set by the shell-served v8-inject.js) → location.ancestorOrigins[0] if it is *.verse8.io?parentOrigin= query if *.verse8.io / local dev. Nothing trusted → no handshake → fallback. Top-level documents never handshake.

Namespacing

You get one namespace per game; the shell derives it from the browser-stamped origin of your CONNECT message, so games cannot read each other’s cache. Inside your namespace, keys are yours — prefix them yourself if you want per-account separation (`${account}:terrain:${seed}`).

Policy (v1)

Default
Per game (namespace)64 MB soft cap → QUOTA_EXCEEDED
Single value32 MB → TOO_LARGE
Key length512 UTF-16 units → TOO_LARGE
Shell total (all games)512 MB → least-recently-used games evicted on next connect

Guarantee level: best-effort. Entries disappear when: the shell total cap is exceeded (LRU), the user clears site data, the browser purges the shell origin (Safari ITP purges script-writable storage of sites without user interaction for 7 days of Safari use), or the app is uninstalled. The shell calls navigator.storage.persist() once to reduce browser-initiated eviction; it is advisory.

FAQ

Q. Is there a synchronous API like localStorage? No. Neither the shell round-trip (postMessage) nor the app’s file I/O can honestly be made synchronous.

Q. I see a “host does not support the local cache” warning in the console. Your game is framed but the shell has not enabled the bridge (older shell, external embed). The SDK falls back to in-frame IndexedDB and keeps working, but on Safari/iOS that storage is ephemeral. Pass init({ silent: true }) to hide the warning.

Q. Why does the app work without a bridge? The Verse8 mobile app loads the game URL as the WebView’s top-level document, so it is first-party. The SDK detects this and uses its own IndexedDB (with the same limits) without a handshake.

Q. Will the cache show up on my other devices? No. The cache exists separately per device, browser, and platform, and there is no sync API. Anything that must be visible everywhere belongs on the game server.

Last updated on