@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
207 lines (181 loc) • 8.22 kB
JavaScript
import { assert } from "../../../core/assert.js";
/**
* Initial-sync state stream generator.
*
* When a new peer joins, it needs the current state of the world before any
* incremental {@link ActionLog} updates make sense. Snapshotter walks every
* entity in scope, asks each replicated component for its serialized state,
* and writes a flat record stream into a `BinaryBuffer`. The receiver reads
* the stream, creates local entities, attaches components, and registers each
* in the local {@link ReplicationSlotTable} at the matching network_id.
*
* This is a one-shot operation, separate from the per-tick action machinery:
* - Uses the same {@link BinaryClassSerializationAdapter}s as the action log.
* - Does NOT go through the executor — initial sync isn't a reversible mutation.
* - Wire format is minimal; no action_type tags, no prior state.
*
* Wire format:
* ```
* varint: entity_count
* loop entity_count times:
* varint: network_id
* uint8: component_count
* loop component_count times:
* uint8: component_type_id
* uint32: payload_len
* bytes: payload (adapter.serialize)
* ```
*
* @author Alex Goldring
* @copyright Company Named Limited (c) 2025
*/
/**
* Walk every entity in the scope and write a snapshot stream into `buffer`.
*
* @param {{
* buffer: BinaryBuffer,
* world: EntityComponentDataset,
* slot_table: ReplicationSlotTable,
* component_registry: ReplicatedComponentRegistry,
* entity_iter: function(function(number): void): void,
* }} options where `entity_iter(cb)` invokes `cb(entity_id)` for each in-scope entity
*/
export function snapshotter_emit({ buffer, world, slot_table, component_registry, entity_iter }) {
assert.isFunction(entity_iter, 'entity_iter');
// Two passes: first count entities (so we can write the count up front),
// then write each. Single-pass with back-patch would also work; counting
// is cheap and keeps the wire format simpler to read.
const entity_ids = [];
entity_iter(id => entity_ids.push(id));
buffer.writeUintVar(entity_ids.length);
for (let i = 0; i < entity_ids.length; i++) {
const entity_id = entity_ids[i];
const network_id = slot_table.network_for(entity_id);
if (network_id < 0) {
throw new Error(`snapshotter_emit: entity ${entity_id} is not registered in the slot table`);
}
// Determine how many replicated components this entity has.
const type_count = component_registry.type_count();
const present_type_ids = [];
for (let type_id = 0; type_id < type_count; type_id++) {
const klass = component_registry.class_of(type_id);
if (world.hasComponent(entity_id, klass)) {
present_type_ids.push(type_id);
}
}
buffer.writeUintVar(network_id);
buffer.writeUint8(present_type_ids.length);
for (let j = 0; j < present_type_ids.length; j++) {
const type_id = present_type_ids[j];
const klass = component_registry.class_of(type_id);
const adapter = component_registry.adapter_for_id(type_id);
const component = world.getComponent(entity_id, klass);
buffer.writeUint8(type_id);
const len_position = buffer.position;
buffer.writeUint32(0);
const payload_start = buffer.position;
adapter.serialize(buffer, component);
const payload_end = buffer.position;
buffer.position = len_position;
buffer.writeUint32(payload_end - payload_start);
buffer.position = payload_end;
}
}
}
/**
* Apply a snapshot stream to a (typically empty) world, creating each entity
* and attaching components.
*
* @param {{
* buffer: BinaryBuffer,
* buffer_end: number,
* world: EntityComponentDataset,
* slot_table: ReplicationSlotTable,
* component_registry: ReplicatedComponentRegistry,
* }} options buffer is read starting from its current position; reading stops at `buffer_end`
*/
export function snapshotter_apply({ buffer, buffer_end, world, slot_table, component_registry }) {
assert.isNonNegativeInteger(buffer_end, 'buffer_end');
const entity_count = buffer.readUintVar();
for (let i = 0; i < entity_count; i++) {
const network_id = buffer.readUintVar();
const component_count = buffer.readUint8();
const local_entity_id = world.createEntity();
slot_table.allocate_at(network_id, local_entity_id);
for (let j = 0; j < component_count; j++) {
const type_id = buffer.readUint8();
const payload_len = buffer.readUint32();
const klass = component_registry.class_of(type_id);
const adapter = component_registry.adapter_for_id(type_id);
if (klass === undefined || adapter === undefined) {
// Unknown component type — skip this payload to stay aligned.
buffer.position += payload_len;
continue;
}
// Allocate a fresh component instance via the class's no-arg constructor.
// This relies on the engine convention that components are constructible without args.
const component = new klass();
adapter.deserialize(buffer, component);
world.addComponentToEntity(local_entity_id, component);
}
if (buffer.position > buffer_end) {
throw new Error(`snapshotter_apply: read past buffer_end at network_id ${network_id}`);
}
}
}
/**
* Apply a snapshot stream to existing entities, updating their components in
* place via `adapter.deserialize`. Wire format is identical to {@link snapshotter_emit};
* only the apply semantics differ.
*
* Used by the recovery flow: server emits the current state of a list of
* mutated entities; client applies those bytes to its already-existing local
* counterparts. Entities the receiver doesn't know about are skipped silently
* (their payload bytes are still consumed so the parser stays aligned).
*
* Skipped also: unknown `component_type_id` (forward-compat with newer servers
* that replicate components the receiver doesn't recognise), and components
* the local entity doesn't currently have (the receiver hasn't been told to
* attach this component — recovery doesn't change entity composition, only state).
*
* @param {{
* buffer: BinaryBuffer,
* buffer_end: number,
* world: EntityComponentDataset,
* slot_table: ReplicationSlotTable,
* component_registry: ReplicatedComponentRegistry,
* }} options
*/
export function snapshotter_apply_to_existing({ buffer, buffer_end, world, slot_table, component_registry }) {
assert.isNonNegativeInteger(buffer_end, 'buffer_end');
const entity_count = buffer.readUintVar();
for (let i = 0; i < entity_count; i++) {
const network_id = buffer.readUintVar();
const component_count = buffer.readUint8();
const local_entity_id = slot_table.entity_for(network_id);
for (let j = 0; j < component_count; j++) {
const type_id = buffer.readUint8();
const payload_len = buffer.readUint32();
const klass = component_registry.class_of(type_id);
const adapter = component_registry.adapter_for_id(type_id);
// Three skip cases — all consume the payload to keep the parser aligned.
if (klass === undefined || adapter === undefined) {
buffer.position += payload_len;
continue;
}
if (local_entity_id < 0) {
buffer.position += payload_len;
continue;
}
const component = world.getComponent(local_entity_id, klass);
if (component === undefined) {
buffer.position += payload_len;
continue;
}
adapter.deserialize(buffer, component);
}
if (buffer.position > buffer_end) {
throw new Error(`snapshotter_apply_to_existing: read past buffer_end at network_id ${network_id}`);
}
}
}