ecsly_serialization 0.1.0-dev.4
ecsly_serialization: ^0.1.0-dev.4 copied to clipboard
Serialization and deserialization plugin for ecsly worlds: JSON snapshots of resources, entities, and SoA component columns.
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.