ecsly_serialization
Serialization and deserialization plugin for ecsly worlds.
Captures and restores JSON snapshots of:
- Entities carrying a
PersistentIdcomponent (identity + SoA columns + object-tier components); - Resources implementing
SnapshotableResource; - Whole worlds via a versioned snapshot envelope (format v3).
Vision and boundaries: NORTH_STAR.md.
Identity model
Runtime Entity handles are world-local and never serialized — they are
regenerated on restore, exactly like Bevy and networked ECS engines treat
entity ids. Stable identity lives in a component:
world.spawnComponents([
const PersistentId(42), // stable cross-session identity
const PositionComponent(),
]);
Only entities carrying PersistentId are captured; particles, VFX, and other
transient entities are runtime-only by default. Restore spawns fresh
entities into the target world — the target can be completely empty.
Install
dart pub add ecsly_serialization
Workspace consumers inside this repo can use a key-only entry
(ecsly_serialization:).
Usage
A complete runnable save/load story lives in example/main.dart
(dart run example/main.dart).
Whole world save/load
// Mark persistable entities with PersistentId at spawn time.
final entity = world.spawnComponents([
const PersistentId(1),
const PositionComponent(),
const HealthComponent(),
]);
final snapshot = captureWorldSnapshot(world);
final encoded = encodeWorldSnapshot(snapshot); // JSON string → disk
// Later — any fresh world, even a completely empty one:
final decoded = decodeWorldSnapshot(encoded);
final mapping = restoreWorldSnapshot(freshWorld, decoded);
// mapping: persistentId → newly spawned Entity
Restore is idempotent: re-applying the same snapshot leaves entities whose
PersistentId already exists in the target world untouched.
Object-tier components
Object components live in an ObjectColumn, so restore needs a way to build
an instance. Register a sample at registration time (the component-side
mirror of the event-side sampleEvent pattern):
world.components.registerObjectComponent<InventoryComponent>(
sample: const InventoryComponent.empty(),
);
Alternatively pass per-call factories:
restoreWorldSnapshot(
target,
snapshot,
componentFactories: {
'InventoryComponent': () => const InventoryComponent.empty(),
},
);
SoA/tag components need no sample — their columns are zero-initialized at
spawn and overwritten from snapshot data. Object values are serialized via
codecs registered in ObjectComponentCodecRegistry.
Structural changes between versions
Component identity travels as type names, so registration reorder, additions,
and removals are handled automatically. Renames and value transforms need an
explicit migration chain keyed on schemaVersion:
final restored = decodeAndMigrateWorldSnapshot(oldSaveJson, [
const RenameComponentMigration(
fromVersion: 1,
oldName: 'Transform',
newName: 'PositionComponent',
),
], targetVersion: 2);
Resources
class GameConfig extends Resource with SnapshotableResource {
GameConfig({this.speed = 100});
final double speed;
@override
Map<String, Object?> toJson() => {'speed': speed};
static GameConfig fromJson(Map<String, Object?> json) =>
GameConfig(speed: (json['speed'] as num).toDouble());
}
restoreWorldSnapshot(target, snapshot, resourceFactories: {
'GameConfig': GameConfig.fromJson,
});
Plugin
world.addPlugin(const SerializationPlugin());
Constraints
- Capture saves only
PersistentIdcarriers; duplicate persistent ids fail capture loudly. - Restore requires object-tier components to have a registered sample or an
explicit
componentFactoriesentry — missing ones throw with the type name. - Renames and value transforms need an explicit
SnapshotMigrationchain; nothing is inferred automatically.
See DX_FAQ.md for recipes and DESIGN_FAQ.md for design rationale.
Libraries
- ecsly_serialization
- Serialization plugin for
ecslyworlds.