Entity Synchronization
Synchronize game entities across the network with automatic delta compression, interpolation, and authority management for smooth multiplayer gameplay.
Entity synchronization is the backbone of multiplayer games. Web Engine provides a powerful, optimized system for syncing entity state across all connected clients with minimal bandwidth and latency. The system handles transform synchronization, delta compression, interpolation, and ownership automatically.
Synchronization Concepts#
Automatic Sync
Mark entities with Networked component and they sync automatically at 20Hz.
Delta Compression
Only changed properties are sent, reducing bandwidth by 70-90%.
Interpolation
Smooth entity movement by blending between server snapshots.
NetworkedEntity Component#
The Networked component marks entities for network replication. Add it to any entity that needs to be synchronized across clients:
import { addComponent, Networked, NetworkedTransform, Transform} from '@web-engine/core'; // Create entityconst playerId = world.addEntity(); // Add transformaddComponent(world, Transform, playerId);Transform.position[playerId]![0] = 0;Transform.position[playerId]![1] = 1;Transform.position[playerId]![2] = 0; // Mark for network synchronizationaddComponent(world, Networked, playerId);Networked.networkId[playerId] = generateNetworkId();Networked.ownerId[playerId] = network.getClientId(); // Enable transform synchronizationaddComponent(world, NetworkedTransform, playerId); // Entity transform now syncs automatically!// Position, rotation, and velocity sync at 20HzNetworked Component Properties#
| Property | Type | Description |
|---|---|---|
| networkId | number | Unique network identifier for this entity |
| ownerId | number | Client ID that owns this entity (for authority) |
| spawnTime | number | Timestamp when entity was spawned |
| lastSyncTime | number | Last time entity state was synchronized |
State Synchronization Flow#
┌─────────────────┐│ Server (Host) ││ ││ 1. Collect all │ ← Query networked entities│ networked ││ entities ││ ││ 2. Compress │ ← Delta compression (only changes)│ state ││ ││ 3. Broadcast │ ─────┐│ updates │ │└─────────────────┘ │ │ ▼ ┌───────────────────────────────┐ │ │ ▼ ▼┌─────────────────┐ ┌─────────────────┐│ Client 1 │ │ Client 2 ││ │ │ ││ 1. Receive │ │ 1. Receive ││ update │ │ update ││ │ │ ││ 2. Decompress │ │ 2. Decompress ││ delta │ │ delta ││ │ │ ││ 3. Add to │ │ 3. Add to ││ snapshot │ │ snapshot ││ buffer │ │ buffer ││ │ │ ││ 4. Interpolate │ │ 4. Interpolate ││ & render │ │ & render │└─────────────────┘ └─────────────────┘The synchronization system runs at a fixed tick rate (20Hz by default) and handles:
- Collection — Query all entities with Networked component
- Delta Compression — Compare current state with previous, send only changes
- Broadcasting — Send compressed state to all clients
- Reception — Clients receive and decompress state updates
- Interpolation — Smooth transitions between snapshots for rendering
Ownership and Authority#
Web Engine uses an authoritative server model where the server is the source of truth. However, clients can have ownership of entities for prediction:
Server Authority#
import { EntitySync } from '@web-engine/core/network'; // On the server/hostconst entitySync = new EntitySync(); // EntitySync automatically:// - Collects all networked entities// - Applies delta compression// - Broadcasts state at 20Hz// - Validates all state changes function onUpdate(world: IWorld, deltaTime: number) { // Only server/host runs authoritative simulation if (!network.isServer) return; // Update entity sync (broadcasts state) entitySync.update(world, performance.now(), octree); // Server validates all entity modifications validateEntityStates(world);}Client Ownership (Prediction)#
// Check if entity is owned by local clientfunction isLocallyOwned(eid: number): boolean { const ownerId = Networked.ownerId[eid]; return ownerId === network.getClientId();} // Apply local prediction for owned entitiesfunction updatePlayerMovement(eid: number, input: InputState) { // Only predict local player if (!isLocallyOwned(eid)) { return; } // Apply movement locally (instant feedback) const velocity = calculateVelocity(input); RigidBody.velocity[eid]![0] = velocity.x; RigidBody.velocity[eid]![1] = velocity.y; RigidBody.velocity[eid]![2] = velocity.z; // Send input to server for validation network.sendPacket({ type: PacketType.INPUT, senderId: network.getClientId(), timestamp: network.getServerTime(), sequence: network.sequenceNumber++, inputs: { move: [input.moveX, input.moveY], look: [input.lookX, input.lookY], actions: input.pressedKeys } }); // Server will validate and send authoritative state // Client reconciles prediction with server state}Prediction vs Interpolation
Use prediction for local player movement (instant feedback). Use interpolation for remote entities (smooth visuals). The system handles both automatically based on ownership.
Snapshot Interpolation#
Interpolation smooths entity movement by blending between server snapshots, hiding network jitter and creating fluid animation:
import { NetworkManager } from '@web-engine/core/network'; const network = NetworkManager.getInstance(); // Snapshots are automatically added by NetworkSystem// on state update reception function interpolateEntity(eid: number, renderTime: number) { // Don't interpolate locally owned entities (they're predicted) if (isLocallyOwned(eid)) { return; } const netId = Networked.networkId[eid]; if (!netId) return; // Get snapshot buffer for this entity const buffer = network.snapshotBuffer.get(netId); if (!buffer || buffer.length < 2) return; // Find two snapshots to interpolate between // Buffer is sorted by timestamp let older: any = null; let newer: any = null; for (let i = 0; i < buffer.length - 1; i++) { const snap = buffer.get(i); const next = buffer.get(i + 1); if (snap && next && snap.timestamp <= renderTime && renderTime <= next.timestamp) { older = snap; newer = next; break; } } if (!older || !newer) return; // Calculate interpolation factor const totalDelta = newer.timestamp - older.timestamp; const currentDelta = renderTime - older.timestamp; const t = Math.min(Math.max(currentDelta / totalDelta, 0), 1); // Interpolate position Transform.position[eid]![0] = lerp(older.pos[0], newer.pos[0], t); Transform.position[eid]![1] = lerp(older.pos[1], newer.pos[1], t); Transform.position[eid]![2] = lerp(older.pos[2], newer.pos[2], t); // Interpolate rotation (quaternion slerp) const q = slerp(older.rot, newer.rot, t); Transform.quaternion[eid]![0] = q[0]; Transform.quaternion[eid]![1] = q[1]; Transform.quaternion[eid]![2] = q[2]; Transform.quaternion[eid]![3] = q[3];} // Linear interpolationfunction lerp(a: number, b: number, t: number): number { return a + (b - a) * t;} // Spherical linear interpolation for quaternionsfunction slerp( q1: [number, number, number, number], q2: [number, number, number, number], t: number): [number, number, number, number] { // ... quaternion slerp implementation return [0, 0, 0, 1]; // simplified}Interpolation Delay#
// Default interpolation delay is 100ms// This provides a buffer for out-of-order packets // Calculate render time with interpolation delayconst INTERPOLATION_DELAY = 100; // ms function getRenderTime(): number { return network.getServerTime() - INTERPOLATION_DELAY;} // Use render time for interpolationconst renderTime = getRenderTime();interpolateEntity(entityId, renderTime); // Tune delay based on network conditions:// - Local multiplayer: 50ms// - Regional servers: 100ms// - Global servers: 150-200msDelta Compression#
Delta compression dramatically reduces bandwidth by only sending changed values. The system uses bitmasks to indicate which fields have changed:
import { DeltaCompressor, DeltaFlags } from '@web-engine/core/network'; // Create delta compressor with pool sizeconst compressor = new DeltaCompressor(4096); // Compress entity stateconst currentState = { netId: 123, pos: [1.5, 2.0, 3.5], rot: [0, 0, 0, 1], vel: [0.5, 0, 0], lastProcessedSequence: 42}; const delta = compressor.compress(currentState); if (delta) { // Check what changed using bitmask const hasPosition = (delta.changedFields & DeltaFlags.POSITION) !== 0; const hasRotation = (delta.changedFields & DeltaFlags.ROTATION) !== 0; const hasVelocity = (delta.changedFields & DeltaFlags.VELOCITY) !== 0; console.log('Changed fields:', { position: hasPosition, rotation: hasRotation, velocity: hasVelocity }); // Delta contains only changed data // Typical size: 20-50 bytes vs 100+ bytes for full state} // Metricsconst metrics = compressor.getMetrics();console.log('Compression ratio:', metrics.compressionRatio); // 0.7-0.9console.log('Bandwidth saved:', (1 - metrics.compressionRatio) * 100 + '%');Compression Thresholds#
To avoid sending insignificant changes caused by floating-point noise, the system uses thresholds:
| Property | Threshold | Reason |
|---|---|---|
| Position | 0.001 units² (1mm) | Ignore tiny position fluctuations |
| Rotation | 0.0001 dot product | Ignore minimal rotation changes |
| Velocity | 0.01 units/s² (1cm/s) | Ignore negligible velocity changes |
Synchronization Frequency#
// Default sync rate is 20Hz (50ms interval)// This provides a good balance of smoothness and bandwidth class EntitySync { private readonly SYNC_RATE = 20; // Hz private readonly SYNC_INTERVAL = 1000 / this.SYNC_RATE; private lastSyncTime = 0; update(world: IWorld, time: number) { // Only sync at fixed rate if (time - this.lastSyncTime < this.SYNC_INTERVAL) { return; } this.lastSyncTime = time; // Broadcast state this.broadcastState(world); }} // Typical sync rates for different scenarios:// - Fast-paced shooter: 30-60Hz// - Competitive game: 20-30Hz (default)// - Casual game: 10-20Hz// - Slow-paced strategy: 5-10HzSync Rate vs Frame Rate
Network sync rate (20Hz) is independent of render frame rate (60+ FPS). Interpolation fills the gap, creating smooth 60 FPS visuals from 20Hz network updates.
Network Level of Detail (LOD)#
Reduce bandwidth for distant entities by lowering their synchronization frequency and precision based on distance:
import { NetworkLODSystem, LOD_PRESETS } from '@web-engine/core/network'; // Create LOD system with custom levelsconst lodSystem = new NetworkLODSystem({ levels: [ { maxDistance: 10, // Within 10m updateRate: 60, // 60Hz (every frame) precision: 16 // High precision }, { maxDistance: 50, // 10-50m updateRate: 30, // 30Hz (every other frame) precision: 12 // Medium precision }, { maxDistance: 200, // 50-200m updateRate: 10, // 10Hz (every 6th frame) precision: 8 // Low precision }, { maxDistance: Infinity, // Beyond 200m updateRate: 2, // 2Hz (every 30th frame) precision: 4 // Very low precision } ]}); // Or use built-in presetsconst lodSystem = new NetworkLODSystem(LOD_PRESETS.BALANCED);// Available presets:// - LOD_PRESETS.PERFORMANCE (fewer updates, lower precision)// - LOD_PRESETS.BALANCED (default)// - LOD_PRESETS.QUALITY (more updates, higher precision) // Process entities with LODconst updates = lodSystem.processEntities( entities, observerPosition, deltaTime); // Only entities that need updates this frame are returnedfor (const update of updates) { const quantizedState = quantizeState( entity.state, update.lodLevel.precision ); sendUpdate(entity.netId, quantizedState);}Spatial Interest Management#
// Configure spatial cullingconst cullingConfig = { maxDistance: 1000, // Don't sync beyond 1km priorityDistance: 50, // High priority within 50m enabled: true}; // EntitySync uses spatial octree for O(log n) queriesconst octree = new NetworkSpatialOctree({ center: [0, 0, 0], size: 2000, maxDepth: 6}); // Query entities in sphere (within sync range)const visibleEntities = octree.queryEntitiesInSphere( playerX, playerY, playerZ, cullingConfig.maxDistance, 'player_' + playerId); // Only sync visible entities// Entities beyond maxDistance are not synced to this client// This scales to 100k+ entities efficientlyCustom Entity Synchronization#
import { defineComponent, Types } from 'bitecs'; // Define custom componentconst Health = defineComponent({ current: Types.f32, maximum: Types.f32, lastDamageTime: Types.f64}); // Sync custom componentfunction syncHealthComponent(eid: number) { // Only sync if changed const currentHealth = Health.current[eid]; const previousHealth = previousHealthMap.get(eid) || 0; if (Math.abs(currentHealth - previousHealth) > 0.1) { // Send custom state update network.sendPacket({ type: PacketType.STATE_UPDATE, senderId: network.getClientId(), timestamp: network.getServerTime(), states: [{ netId: Networked.networkId[eid], customData: { health: currentHealth, maxHealth: Health.maximum[eid] } }] }); previousHealthMap.set(eid, currentHealth); }} // Receive custom statenetwork.on('stateUpdate', (packet) => { for (const state of packet.states) { const eid = findEntityByNetId(state.netId); if (eid && state.customData?.health !== undefined) { Health.current[eid] = state.customData.health; Health.maximum[eid] = state.customData.maxHealth; // Update health bar UI updateHealthBar(eid); } }});Synchronization Best Practices#
- Mark only essential entities — Not every entity needs to be networked. Sync only player characters, projectiles, and interactive objects.
- Use delta compression — It's enabled by default and provides massive bandwidth savings (70-90%).
- Tune LOD distances — Adjust LOD thresholds based on your game's scale and player density.
- Spatial culling — Enable spatial interest management for large worlds with many entities.
- Predict local entities — Use client-side prediction for local player to eliminate input lag.
- Interpolate remote entities — Always interpolate remote entities for smooth visuals.
- Monitor bandwidth — Track bytes sent/received per second to identify optimization opportunities.
- Batch updates — Send state updates at fixed rate (20Hz) rather than every frame.
Performance Characteristics#
// DeltaCompressor Performance:// - compress(): O(1), zero allocations, ~0.5μs per entity// - Pool capacity: 2048-4096 deltas (configurable)// - Compression ratio: 70-90% bandwidth reduction // Snapshot Interpolation:// - Binary search for bracketing: O(log n)// - Interpolation: O(1)// - Buffer size: 64 snapshots (~1 second at 60fps)// - Zero allocations in hot path // Network LOD System:// - Spatial partitioning: O(log n) for distance queries// - Update culling: O(n) but only processes visible entities// - Can handle 100k+ entities at 60fps // Bandwidth (typical):// - Per entity per update: 20-50 bytes (with delta compression)// - 100 entities at 20Hz: ~10-20 KB/s// - Scales linearly with entity count and update rate