Web Engine Docs
Preparing documentation
Use the search bar to quickly find any topic
Preparing documentation
Use the search bar to quickly find any topic
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.
Mark entities with Networked component and they sync automatically at 20Hz.
Only changed properties are sent, reducing bandwidth by 70-90%.
Smooth entity movement by blending between server snapshots.
The Networked component marks entities for network replication. Add it to any entity that needs to be synchronized across clients:
import { addEntity, addComponent } from 'bitecs';import { Networked, Transform } from '@web-engine-dev/core';// Create entityconst playerId = addEntity(world);// Add transformaddComponent(world, Transform, playerId);Transform.position.x[playerId] = 0;Transform.position.y[playerId] = 1;Transform.position.z[playerId] = 0;// Mark for network synchronizationaddComponent(world, Networked, playerId);Networked.networkId[playerId] = generateNetworkId();Networked.ownerId[playerId] = network.getClientId();Networked.authoritative[playerId] = 1;// Entity transform now syncs automatically!// Position, rotation, and velocity sync at 20Hz
| 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 |
┌─────────────────┐│ 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:
Web Engine uses an authoritative server model where the server is the source of truth. However, clients can have ownership of entities for prediction:
import { NetworkManager, NetworkSystem } from '@web-engine-dev/core';// Get network manager instanceconst network = NetworkManager.getInstance();// NetworkSystem automatically handles:// - Collecting all networked entities// - Applying delta compression// - Broadcasting state at 20Hz// - Validating all state changesfunction onUpdate(world: IWorld, deltaTime: number) {// Only server/host runs authoritative simulationif (!network.isServer()) return;// NetworkSystem runs as part of updateSystems()// Server validates all entity modificationsvalidateEntityStates(world);}
// 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 playerif (!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 validationnetwork.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.
Interpolation smooths entity movement by blending between server snapshots, hiding network jitter and creating fluid animation:
import { NetworkManager } from '@web-engine-dev/core';const network = NetworkManager.getInstance();// Snapshots are automatically added by NetworkSystem// on state update receptionfunction 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 entityconst buffer = network.snapshotBuffer.get(netId);if (!buffer || buffer.length < 2) return;// Find two snapshots to interpolate between// Buffer is sorted by timestamplet 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 factorconst totalDelta = newer.timestamp - older.timestamp;const currentDelta = renderTime - older.timestamp;const t = Math.min(Math.max(currentDelta / totalDelta, 0), 1);// Interpolate positionTransform.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 implementationreturn [0, 0, 0, 1]; // simplified}
// Default interpolation delay is 100ms// This provides a buffer for out-of-order packets// Calculate render time with interpolation delayconst INTERPOLATION_DELAY = 100; // msfunction 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-200ms
Delta compression dramatically reduces bandwidth by only sending changed values. The system uses bitmasks to indicate which fields have changed:
import { Network } from '@web-engine-dev/core';// Create delta compressor with pool sizeconst compressor = new Network.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 bitmaskconst { DeltaFlags } = Network;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 + '%');
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 |
// Default sync rate is 20Hz (50ms interval)// This provides a good balance of smoothness and bandwidthclass EntitySync {private readonly SYNC_RATE = 20; // Hzprivate readonly SYNC_INTERVAL = 1000 / this.SYNC_RATE;private lastSyncTime = 0;update(world: IWorld, time: number) {// Only sync at fixed rateif (time - this.lastSyncTime < this.SYNC_INTERVAL) {return;}this.lastSyncTime = time;// Broadcast statethis.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.
Reduce bandwidth for distant entities by lowering their synchronization frequency and precision based on distance:
import { Network } from '@web-engine-dev/core';const { NetworkLODSystem, LOD_PRESETS } = Network;// Create LOD system with custom levelsconst lodSystem = new NetworkLODSystem({levels: [{maxDistance: 10, // Within 10mupdateRate: 60, // 60Hz (every frame)precision: 16 // High precision},{maxDistance: 50, // 10-50mupdateRate: 30, // 30Hz (every other frame)precision: 12 // Medium precision},{maxDistance: 200, // 50-200mupdateRate: 10, // 10Hz (every 6th frame)precision: 8 // Low precision},{maxDistance: Infinity, // Beyond 200mupdateRate: 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);}
// Configure spatial cullingconst cullingConfig = {maxDistance: 1000, // Don't sync beyond 1kmpriorityDistance: 50, // High priority within 50menabled: 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 efficiently
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 changedconst currentHealth = Health.current[eid];const previousHealth = previousHealthMap.get(eid) || 0;if (Math.abs(currentHealth - previousHealth) > 0.1) {// Send custom state updatenetwork.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 UIupdateHealthBar(eid);}}});
// 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