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.
There is no save(name) / load(name) facade. You assemble the four pieces yourself - a serializer, a buffer, the registry, and a Storage - which is a dozen lines and leaves you owning the slot naming, the metadata, and the scrubbing.
The binary format
A save is a flat ArrayBuffer. The top-level layout is:
| Offset | Field | Type | Notes |
|---|---|---|---|
| 0 | format version | uint16 | currently 0 |
| 2 | component-type count | uint16 | patched in after writing |
| 4+ | component-type blocks | variable | one 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).
Writing a save
BinaryBufferSerializer and BinaryBufferDeSerializer drive the process. Both are default exports:
import BinaryBufferSerializer from "@woosh/meep-engine/src/engine/ecs/storage/BinaryBufferSerializer.js";
import { EncodingBinaryBuffer } from "@woosh/meep-engine/src/core/binary/EncodingBinaryBuffer.js";
import { EndianType } from "@woosh/meep-engine/src/core/binary/EndianType.js";
const serializer = new BinaryBufferSerializer();
serializer.engine = engine; // context handed to adapters
serializer.registry = engine.binarySerializationRegistry;
const buffer = new EncodingBinaryBuffer();
buffer.endianness = EndianType.BigEndian; // what the engine's own level files use
serializer.process(buffer, ecd);
buffer.trim(); // shrink capacity to what was written
await engine.storage.promiseStoreBinary("slot-1", buffer.data);
process(buffer, dataset) is synchronous and throws if serialization itself fails, so a truncated file is never produced silently. A component type with no registered adapter is skipped with a logged error rather than aborting the save - register an adapter for every type the world must keep.
engine.storage is whatever the platform handed the engine (IndexedDBStorage in a browser); see Storage backends.
Reading it back
import BinaryBufferDeSerializer from "@woosh/meep-engine/src/engine/ecs/storage/BinaryBufferDeSerializer.js";
const bytes = await engine.storage.promiseLoadBinary("slot-1");
const deSerializer = new BinaryBufferDeSerializer();
deSerializer.registry = engine.binarySerializationRegistry;
const buffer = new EncodingBinaryBuffer();
buffer.endianness = EndianType.BigEndian;
buffer.fromArrayBuffer(bytes);
const task = deSerializer.process(buffer, engine, ecd);
engine.executor.run(task);
await task.promise();
process(buffer, context, dataset) returns a Task rather than completing synchronously, so a large world loads in incremental slices instead of blocking the main thread for the whole read. Hand it to engine.executor and await its promise, or drive it yourself with task.executeSync() when blocking is what you want (tests, tooling).
context is passed through to every adapter’s deserialize - the engine is the usual answer, and it is what the engine’s own level loader passes.
Deserialization applies file entity IDs onto the dataset you hand it, so load into a clean EntityComponentDataset rather than a populated one, and give it the component type map first:
ecd.setComponentTypeMap(engine.entityManager.getComponentTypeMap());
Loading a level file by URL
For content shipped with the game rather than written by the player, src/engine/scene/SerializedScene.js wraps the same flow around the asset manager - it pulls the bytes with assetManager.promise(path, GameAssetType.ArrayBuffer) and returns the deserialization task:
| Export | Signature |
|---|---|
loadSerializedScene | (path, ecd, engine) => Promise<void> - builds the task, runs it on engine.executor, resolves when done |
createSceneDeserializationTask | (path, ecd, engine) => Promise<Task> - the same up to running it, for a caller that wants to schedule it |
SerializedScene | a Scene subclass whose setup loads path into its own dataset |
This module cannot currently be imported from the published package. It imports
MirScene.jsthrough a relative path that climbs above the package root, and that file does not ship. Until it is fixed, copy the eight lines ofcreateSceneDeserializationTaskinto your own code - the reading flow above is exactly what it does.
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.
What the engine registers for you
Engine creates engine.binarySerializationRegistry empty. Populating it with the engine’s own component adapters is one call, and the host makes it:
import { populateEngineSerializationRegistry } from "@woosh/meep-engine/src/engine/ecs/storage/populateEngineSerializationRegistry.js";
populateEngineSerializationRegistry(engine.binarySerializationRegistry);
That covers every engine-side component type - Transform64, Name, Tag, Team, Camera, Light, SGMesh, Decal, Terrain, Water, FogOfWar, Path/PathFollower, ParticleEmitter, Blackboard, SerializationMetadata, the grid and GUI components, the behaviour-tree nodes, InverseKinematics, and the audio components - plus the registered upgraders for the types whose layouts have moved. It registers the sopra and acoustic value-type adapters first, because an AudioEmitter’s clip graph is reached through the object adapter built from this same registry.
The registry keeps the first registration and warns on duplicates, so layer your own adapters on top and do not re-register engine types.
Two absences worth knowing. There is no adapter for a v1 Mesh component - SGMeshSerializationAdapter persists a SGMesh as its URL plus two shadow flags, and deliberately does not persist opacity or a material override. And there is no AnimationGraphController adapter: animation-graph runtime state does not round-trip.
A save that carries the legacy sound components deserializes into data nothing plays - see Events & mixing for the conversion step a host runs after loading one.
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.
| Class | Persistence | Use case |
|---|---|---|
IndexedDBStorage | browser indexedDB | production browser saves |
InMemoryStorage | JS Map, lost on page unload | tests, server-side, tooling |
Both extend Storage (a default export), 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. Its only constructor argument is that database name:
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.
The engine also uses engine.storage for its own options, under the hardcoded key lazykitty.komrade.options. Pick save slot names that will not collide with it.
StorageBackedList (src/engine/save/StorageBackedList.js) is a small helper for the list-of-slot-names problem every save UI has.
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, Transform64);
}
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.
Related
- Scenes - the
EntityComponentDatasetthe serializer walks. - Asset pipeline - a saved
SGMeshis a URL, so loading a save re-runs the model loads. - Determinism - fixed-step physics; the same save replayed with the same inputs produces the same world.