Platform

Saving & loading

How Meep serializes ECS world state to binary, migrates saved data across format versions, and persists it to IndexedDB or an in-memory store.

Meep serializes the entire ECS world to a compact binary buffer and writes it to a pluggable storage backend. Loading reads the buffer back into a fresh EntityComponentDataset, running any necessary format migrations along the way. The whole pipeline is component-driven: each component class owns its serialization adapter, and the engine assembles saves by walking every registered component type.

The binary format

A save is a flat ArrayBuffer. The top-level layout is:

OffsetFieldTypeNotes
0format versionuint16currently 0
2component-type countuint16patched in after writing
4+component-type blocksvariableone block per non-empty type

Each component-type block begins with the type’s typeName string, its adapter version, and a dictionary header (for deduplicated string values), followed by per-entity records. Entities are stored as ascending ID deltas — each record writes only the step from the previous entity’s ID rather than the absolute ID — so the data compresses well and the decoder reconstructs entity IDs incrementally.

Transient entities and components are skipped during the write pass (see Transient marking).

BinaryBufferSerializer and BinaryBufferDeSerializer are the two classes that drive this process. The deserializer’s process method returns a Task rather than completing synchronously, so large worlds can be loaded in incremental slices without blocking the main thread.

Per-component adapters

Every component class that participates in saving must have a BinaryClassSerializationAdapter registered with the session’s BinarySerializationRegistry. The adapter declares which class it handles (klass) and a monotonically-increasing version number:

import { BinaryClassSerializationAdapter } from "@woosh/meep-engine/src/engine/ecs/storage/binary/BinaryClassSerializationAdapter.js";
import { Inventory } from "./Inventory.js";

class InventorySerializer extends BinaryClassSerializationAdapter {
    klass = Inventory;
    version = 1;

    serialize(buffer, inv) {
        buffer.writeUint32(inv.gold);
        buffer.writeUint32(inv.items.length);
        for (const item of inv.items) buffer.writeUTF8String(item);
    }

    deserialize(buffer, inv) {
        inv.gold = buffer.readUint32();
        const count = buffer.readUint32();
        inv.items = [];
        for (let i = 0; i < count; i++) inv.items.push(buffer.readUTF8String());
    }
}

Register adapters on the BinarySerializationRegistry before the first save or load:

import { BinarySerializationRegistry } from "@woosh/meep-engine/src/engine/ecs/storage/binary/BinarySerializationRegistry.js";

const registry = new BinarySerializationRegistry();
registry.registerAdapter(new InventorySerializer());

If a component class has serializable = false as a static property, the serializer skips it entirely — useful for purely runtime state like cached spatial indices.

Versioned migration

When a component’s binary layout changes, increment version on the new adapter and register a BinaryClassUpgrader for each supported upgrade path.

An upgrader declares a __startVersion and __targetVersion, and its upgrade(source, target) method reads the old format from source and writes the new format to target. The real Tag component ships TagSerializationUpgrader_0_1 as an example — version 0 stored a single string; version 1 stores a count followed by an array of strings:

import { BinaryClassUpgrader } from "@woosh/meep-engine/src/engine/ecs/storage/binary/BinaryClassUpgrader.js";

class InventoryUpgrader_0_1 extends BinaryClassUpgrader {
    constructor() {
        super();
        this.__startVersion = 0;
        this.__targetVersion = 1;
    }

    upgrade(source, target) {
        // v0: just a gold value
        // v1: gold + item count (always 0 for legacy saves)
        target.writeUint32(source.readUint32());  // gold
        target.writeUint32(0);                     // items: none
    }
}

registry.registerUpgrader("Inventory", new InventoryUpgrader_0_1());

BinarySerializationRegistry.getUpgradersChain(className, startVersion, goalVersion) finds the shortest path through the registered upgrader graph from any older version to the current one. executeBinaryClassUpgraderChain then runs the chain by ping-ponging between two scratch BinaryBuffer instances — no intermediate allocations. This means you can have gaps in your upgrade graph (e.g. upgraders for 0→1 and 1→2 but not 0→2) and the registry will chain them automatically.

Storage backends

The engine ships two concrete Storage implementations.

ClassPersistenceUse case
IndexedDBStoragebrowser indexedDBproduction browser saves
InMemoryStorageJS Map, lost on page unloadtests, server-side, tooling

Both extend Storage, which defines the interface: storeBinary, loadBinary, store, load, list, remove, contains, and Promise-returning variants of each (promiseStoreBinary, promiseLoadBinary, promiseList, promiseRemove, promiseContains).

IndexedDBStorage wraps a single named IndexedDB database with a main object store:

import { IndexedDBStorage } from "@woosh/meep-engine/src/engine/save/storage/IndexedDBStorage.js";
import { InMemoryStorage } from "@woosh/meep-engine/src/engine/save/storage/InMemoryStorage.js";

// Browser
const storage = new IndexedDBStorage("my-game-saves");

// Tests / Node
const storage = new InMemoryStorage();

Custom backends implement the same Storage interface — a file-system backend for Electron, a remote-API backend for cloud saves, or a versioned wrapper that keeps rolling snapshots.

GameStateLoader (an EnginePlugin) ties the high-level save(name) / load(name) / exists(name) API to the storage backend wired up on the engine. It delegates to GameSaveStateManager for metadata (name, timestamp, locked flag) and picks the most recent non-locked slot when multiple saves share a name.

Transient entities and components

Not everything in the world should survive a save. Particles, preview objects, and ephemeral UI entities should be excluded. There are two ways to mark state as transient.

Entity-level: add a SerializationMetadata component with the Transient flag set. The engine provides the frozen singleton SerializationMetadata.Transient for this:

import { SerializationMetadata, SerializationFlags } from "@woosh/meep-engine/src/engine/ecs/components/SerializationMetadata.js";

// Option A — use the pre-built singleton
new Entity()
    .add(SerializationMetadata.Transient)
    .add(particleEmitter)
    .build(ecd);

// Option B — build a custom flags value
const sm = new SerializationMetadata();
sm.setFlag(SerializationFlags.Transient);

Component-instance-level: set the magic field COMPONENT_SERIALIZATION_TRANSIENT_FIELD ('@serialization_transient') to true on an individual component instance. This skips that specific component on that specific entity even if the entity itself is not transient.

SerializationMetadata itself is serialized (its adapter is SerializationMetadataSerializationAdapter), so the transient flag can survive a load if you want to mark a class of entities as permanently non-saveable across reloads — though typically you would simply not add SerializationMetadata to entities that need to be created fresh on each load.

Entity references across save/load

When one component holds a reference to another entity, raw integer entity IDs are unsafe across saves: IDs are reused after entity deletion, and the loaded world may assign different IDs than the original. EntityReference solves this by pairing an entity ID with a generation counter:

import { EntityReference } from "@woosh/meep-engine/src/engine/ecs/EntityReference.js";

// Bind a reference
const ref = EntityReference.bind(ecd, entityId);

// Verify after load — returns false if the entity no longer exists
// or if the ID has been recycled for a different entity
if (ref.verify(ecd)) {
    const transform = ecd.getComponent(ref.id, Transform);
}

EntityReference.NULL is a frozen sentinel (id = -1, generation = -1) representing “no entity.” Serialization adapters for components that hold cross-entity references should serialize the pair (id, generation) and restore it on deserialize, then call verify on first use after loading.

  • Scenes & references — the EntityComponentDataset the serializer walks.
  • Determinism — fixed-step physics; the same save replayed with the same inputs produces the same world.