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:

Basic Networked Entity
typescript
import {
addComponent,
Networked,
NetworkedTransform,
Transform
} from '@web-engine/core';
// Create entity
const playerId = world.addEntity();
// Add transform
addComponent(world, Transform, playerId);
Transform.position[playerId]![0] = 0;
Transform.position[playerId]![1] = 1;
Transform.position[playerId]![2] = 0;
// Mark for network synchronization
addComponent(world, Networked, playerId);
Networked.networkId[playerId] = generateNetworkId();
Networked.ownerId[playerId] = network.getClientId();
// Enable transform synchronization
addComponent(world, NetworkedTransform, playerId);
// Entity transform now syncs automatically!
// Position, rotation, and velocity sync at 20Hz

Networked Component Properties#

PropertyTypeDescription
networkIdnumberUnique network identifier for this entity
ownerIdnumberClient ID that owns this entity (for authority)
spawnTimenumberTimestamp when entity was spawned
lastSyncTimenumberLast 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#

Server-Side Entity Management
typescript
import { EntitySync } from '@web-engine/core/network';
// On the server/host
const 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)#

Client-Side Prediction
typescript
// Check if entity is owned by local client
function isLocallyOwned(eid: number): boolean {
const ownerId = Networked.ownerId[eid];
return ownerId === network.getClientId();
}
// Apply local prediction for owned entities
function 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:

Interpolation System
typescript
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 interpolation
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
// Spherical linear interpolation for quaternions
function 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#

Configuring Interpolation Delay
typescript
// Default interpolation delay is 100ms
// This provides a buffer for out-of-order packets
// Calculate render time with interpolation delay
const INTERPOLATION_DELAY = 100; // ms
function getRenderTime(): number {
return network.getServerTime() - INTERPOLATION_DELAY;
}
// Use render time for interpolation
const renderTime = getRenderTime();
interpolateEntity(entityId, renderTime);
// Tune delay based on network conditions:
// - Local multiplayer: 50ms
// - Regional servers: 100ms
// - Global servers: 150-200ms

Delta Compression#

Delta compression dramatically reduces bandwidth by only sending changed values. The system uses bitmasks to indicate which fields have changed:

Delta Compression Example
typescript
import { DeltaCompressor, DeltaFlags } from '@web-engine/core/network';
// Create delta compressor with pool size
const compressor = new DeltaCompressor(4096);
// Compress entity state
const 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
}
// Metrics
const metrics = compressor.getMetrics();
console.log('Compression ratio:', metrics.compressionRatio); // 0.7-0.9
console.log('Bandwidth saved:', (1 - metrics.compressionRatio) * 100 + '%');

Compression Thresholds#

To avoid sending insignificant changes caused by floating-point noise, the system uses thresholds:

PropertyThresholdReason
Position0.001 units² (1mm)Ignore tiny position fluctuations
Rotation0.0001 dot productIgnore minimal rotation changes
Velocity0.01 units/s² (1cm/s)Ignore negligible velocity changes

Synchronization Frequency#

Configuring Sync Rate
typescript
// 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-10Hz

Sync 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:

Network LOD Configuration
typescript
import { NetworkLODSystem, LOD_PRESETS } from '@web-engine/core/network';
// Create LOD system with custom levels
const 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 presets
const 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 LOD
const updates = lodSystem.processEntities(
entities,
observerPosition,
deltaTime
);
// Only entities that need updates this frame are returned
for (const update of updates) {
const quantizedState = quantizeState(
entity.state,
update.lodLevel.precision
);
sendUpdate(entity.netId, quantizedState);
}

Spatial Interest Management#

Distance-Based Culling
typescript
// Configure spatial culling
const cullingConfig = {
maxDistance: 1000, // Don't sync beyond 1km
priorityDistance: 50, // High priority within 50m
enabled: true
};
// EntitySync uses spatial octree for O(log n) queries
const 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 efficiently

Custom Entity Synchronization#

Sync Custom Components
typescript
import { defineComponent, Types } from 'bitecs';
// Define custom component
const Health = defineComponent({
current: Types.f32,
maximum: Types.f32,
lastDamageTime: Types.f64
});
// Sync custom component
function 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 state
network.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#

System Performance
typescript
// 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
Multiplayer | Web Engine Docs | Web Engine Docs