A Crafty world is not a fixed level shipped with the game — it is something the player creates, digs into, builds in, and returns to days later expecting to find exactly as they left it. That promise is the job of the persistence layer. This chapter covers how Crafty saves single-player worlds locally in the browser, how a complete world is reconstructed from a seed plus an edit log rather than a stored block grid, and how schema versioning keeps old saves loading as the format evolves. The server-authoritative persistence used in networked worlds is the subject of Chapter 8: Multiplayer Gameplay; here we stay on the client.
The SavedWorld Record#
Crafty saves local worlds in the browser's IndexedDB via the WorldStorage class (crafty/game/world_storage.ts). Each world is stored as a single record in a worlds object store keyed by a UUID:
export interface SavedWorld
{
id: string;
name: string;
seed: number;
createdAt: number;
lastPlayedAt: number;
edits: BlockEdit[];
player: { x: number; y: number; z: number; yaw: number; pitch: number };
sunAngle: number;
/** Game mode. Missing (pre-survival saves) loads as 'creative'. */
gameMode?: GameMode;
/** Persisted survival inventory: gathered stacks + the exact hotbar layout.
* Only written for survival worlds; restored on load. */
inventory?: InventoryState;
/** Moon phase in [0, 1] (0/1 = new, 0.5 = full). */
moonPhase?: number;
/** JPEG thumbnail (~160×90), updated less frequently than the rest of the record. */
screenshot?: Blob;
/** Schema version; missing means pre-versioning (treat as 0). */
version?: number;
}The record is deliberately small. The expensive part of a voxel world — the block grid itself — is not stored. Only the seed and the edits array are kept, and the full world is regenerated from them on load (§9.5). Everything else is lightweight session state: where the player was standing, the sun angle and moon phase, the game mode, and an optional JPEG thumbnail for the launcher.
Database Schema#
The IndexedDB database is opened with a single object store. An onupgradeneeded handler creates the store if it doesn't exist — IndexedDB ignores duplicate creations, so repeated opens are safe:
static open(): Promise<WorldStorage>
{
return new Promise<WorldStorage>((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE)) {
db.createObjectStore(STORE, { keyPath: 'id' });
}
};
req.onsuccess = () => resolve(new WorldStorage(req.result));
req.onerror = () => reject(req.error ?? new Error('IndexedDB open failed'));
});
}CRUD Operations#
All operations follow a consistent pattern: begin a transaction, get the object store, and wrap the request in a promise:
save(world: SavedWorld): Promise<void>
{
return this._withStore('readwrite', (store) => {
return new Promise<void>((resolve, reject) => {
const req = store.put(world);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error ?? new Error('IndexedDB save failed'));
});
});
}
list(): Promise<SavedWorld[]>
{
return this._withStore('readonly', (store) => {
return new Promise<SavedWorld[]>((resolve, reject) => {
const req = store.getAll();
req.onsuccess = () => {
const all = (req.result ?? []) as SavedWorld[];
all.sort((a, b) => b.lastPlayedAt - a.lastPlayedAt);
resolve(all);
};
req.onerror = () => reject(req.error ?? new Error('IndexedDB list failed'));
});
});
}Results are sorted by lastPlayedAt descending so the launcher can show the most recently played world first. The _withStore helper reduces boilerplate by wrapping the transaction + object store setup:
private _withStore<T>(mode: IDBTransactionMode, fn: (store: IDBObjectStore) => Promise<T>): Promise<T>
{
const tx = this._db.transaction(STORE, mode);
const store = tx.objectStore(STORE);
return fn(store);
}Schema Versioning#
A version field on each record enables forward-compatible schema changes. Records with a missing or lower version are detected and migrated when loaded:
export const CURRENT_FORMAT_VERSION = 1;The version check happens in the load path — if loaded.version < CURRENT_FORMAT_VERSION, the application applies upgrade transforms before exposing the record. This avoids maintaining migration logic inside the storage layer itself.
World Record Lifecycle#
Worlds are created via the factory function createSavedWorld, which populates sensible defaults for a fresh world:
export function createSavedWorld(name: string, seed: number, gameMode: GameMode = 'creative'): SavedWorld
{
const now = Date.now();
return {
id: _randomId(),
name,
seed,
createdAt: now,
lastPlayedAt: now,
edits: [],
player: { x: 64, y: 80, z: 64, yaw: 0, pitch: 0 },
sunAngle: Math.PI * 0.3,
gameMode,
version: CURRENT_FORMAT_VERSION,
};
}During gameplay, the world record is updated periodically (every ~5 seconds) through a debounced autosave in main.ts. The edits array grows monotonically — every block placement or destruction appends a BlockEdit entry. This gives a complete undo history and enables replay-based persistence: the world is re-generated from the seed plus the edit log rather than storing the full block grid.
The start screen uses storage.list() to populate the saved-world selector and storage.delete() to remove worlds, with the screenshot Blob providing a visual thumbnail for each entry.
Survival Inventory#
Survival worlds also persist what the player has gathered. The counted inventory lives in crafty/game/inventory.ts as a Map<BlockType, number> (counts keyed by block type, in acquisition order), and the on-screen hotbar (crafty/ui/hotbar.ts) owns a parallel slot layout — which block type sits in each of the nine slots. The same autosave that flushes position and gameMode captures both into SavedWorld.inventory:
savedWorld.gameMode = inventory.mode;
// Only survival worlds carry an inventory; clear it in creative so a world
// toggled back to creative doesn't keep a stale stack list.
savedWorld.inventory = inventory.mode === 'survival'
? { stacks: inventory.items().map(s => ({ blockType: s.type, count: s.count })),
hotbar: [...hotbar.slots] }
: undefined;On load, inventory.restore(...) repopulates the counts and hotbar.setSlots(...) rebuilds the bar exactly as it was left, so reopening a world restores both what you carry and where it sits on the bar. Creative worlds store no inventory (every block is unlimited), so the field stays absent and the launcher's pre-survival saves keep working unchanged. The InventoryState shape ({ stacks, hotbar }) is shared with the multiplayer protocol — in a networked survival world the same payload is persisted per-player on the server instead (see Taos Engine Chapter 16 §16.5).
Summary#
Crafty's world persistence layer provides:
- A compact save record:
SavedWorldstores a seed, an edit log, and lightweight session state (player pose, sun/moon, game mode, thumbnail) — never the full block grid - IndexedDB storage: a single object store keyed by UUID, with promise-wrapped CRUD and an idempotent
onupgradeneededschema bootstrap - Edit-log replay: worlds are reconstructed from
seed+edits, giving complete history and a tiny on-disk footprint - Schema versioning: a
versionfield plus a load-time migration hook keep older saves loading as the format evolves - Survival inventory: gathered stacks and the exact hotbar layout round-trip through the same autosave, sharing the
{ stacks, hotbar }shape with the multiplayer protocol
The same world state, in a networked game, is persisted server-side per player — see Chapter 8: Multiplayer Gameplay and Taos Engine Chapter 16 §16.5.
Further reading:
- crafty/game/world_storage.ts —
WorldStorage,SavedWorld,createSavedWorld, and the CRUD/versioning logic - crafty/game/inventory.ts — the counted survival inventory restored on load
- crafty/main.ts — the debounced autosave (
_flushSave) that drives persistence during play