Skip to content

Networking

Estella ships a cross-platform WebSocket. createSocket returns the right implementation for the platform — a browser WebSocket on the web, wx.connectSocket on WeChat MiniGames — behind one API. On top of it, NetChannel adds typed messages and request/response.

import { createSocket } from 'esengine';
const socket = createSocket({ url: 'wss://example.com/game' });
socket.on('open', () => {
socket.send(JSON.stringify({ type: 'join', room: 'lobby' }));
});
socket.on('message', (data) => {
const msg = JSON.parse(data as string); // a message from the server
});
socket.on('close', (code, reason) => { /* reconnect? */ });
socket.on('error', (err) => { /* … */ });
socket.connect();
Member Description
connect() Open the connection.
send(data) Send a string or ArrayBuffer; data sent before open is queued and flushed on connect.
close(code?, reason?) Disconnect.
readyState 'connecting' | 'open' | 'closing' | 'closed'.
on(event, fn) Subscribe to 'open' / 'message' / 'close' / 'error'; returns an unsubscribe function.

NetChannel wraps a transport with routed, typed messages and request/response, so you send by message type instead of hand-parsing every frame:

import { NetChannel } from 'esengine';
const channel = new NetChannel(socket);
// Fire-and-forget events, routed by type.
const off = channel.on<{ x: number; y: number }>('move', (p) => { /* … */ });
channel.send('move', { x: 3, y: 5 });
// Request/response (RPC): resolves with the reply, rejects on timeout/remote error.
channel.handle<{ id: string }, { hp: number }>('getStats', async (req) => {
return { hp: 100 };
});
const stats = await channel.request<{ hp: number }>('getStats', { id: 'p1' }, 5000);
Method Description
on(type, handler) Subscribe to a message type; returns an unsubscribe fn.
send(type, payload) Send a typed event.
handle(type, handler) Register an RPC handler; returns an unsubscribe fn.
request(type, payload, timeoutMs?) Send an RPC; resolves with the reply.
dispose(reason?) Tear down the channel and reject pending requests.

State replication — multiplayer entities

Section titled “State replication — multiplayer entities”

On top of the transport, Estella ships a server-authoritative replication layer: mark an entity Replicated on the server and it spawns on every client, its declared fields stream as binary deltas, and remote pawns render with snapshot interpolation. One declaration drives the wire format — no hand-written sync code.

import { defineComponent, Net, Replicated, Transform, createMemoryTransportPair } from 'esengine';
// Which fields replicate is declared on the component (builtins like
// Transform annotate `replicated` in C++; user components declare it here).
const Health = defineComponent('Health', { hp: 100, regen: 1 }, {
replicatedFields: ['hp'],
});
// A transport carries frames between two ends. In-process (tests / a listen
// server) use a memory pair; a real net puts a GameSocket on each side.
const [serverEnd, clientEnd] = createMemoryTransportPair();
// --- SERVER app (the authority) ---
const server = serverApp.getResource(Net).startServer();
const connectionId = server.attachConnection(serverEnd); // returns the connection id
const e = serverApp.world.spawn('player');
serverApp.world.insert(e, Transform, { position: { x: 0, y: 0, z: 0 } });
serverApp.world.insert(e, Health, {});
serverApp.world.insert(e, Replicated, { owner: connectionId }); // owned by that client
const input = server.inputOf(connectionId); // that client's latest input
// server.clientIds — poll each tick to spawn/retire a pawn per connected player.
// --- CLIENT app ---
const clientNet = clientApp.getResource(Net);
await clientNet.connect(clientEnd, { interpolationDelayTicks: 2 }); // 2 = smoothing lag
clientNet.client?.sendInput({ move: { x: 1, y: 0 } }); // per-tick input uplink
  • Authority: only the server simulates; clients see replicated, interpolated state (NetGhost tags the proxies). The handshake refuses a protocol/ABI/schema mismatch fail-loud, so drifted builds never desync silently.
  • Ownership: Replicated.owner routes a connection’s sendInput commands to its entities; client.ownsEntity(e) answers “is this ghost mine”.
  • Editor preview: pick 2–4 Players in the Play-mode dropdown — a listen server plus client views run side by side, zero networking setup.
  • Dedicated servers: the same gameplay code runs headless under Node — import { loadEsengineModule, createHeadlessApp, runHeadless } from 'esengine/node'.

See the Multiplayer Arena example for the full pattern — replication, per-tick input, and client prediction — in ~130 lines.

By default every client receives every replicated entity. For bigger worlds, install an interest policy on the server: each connection then only receives the entities relevant to it — entering entities arrive as full spawns, leaving ones despawn their client ghost, and delta frames carry only what’s inside.

import { radiusInterest } from 'esengine';
// Each client sees entities within 800 units of any entity it owns.
server.setInterestPolicy(radiusInterest(800));
// Or any custom rule — return the relevant subset (or 'all'):
server.setInterestPolicy(({ connectionId, world, candidates }) => {
const visible = new Set(candidates.filter((e) => isRelevantTo(world, e, connectionId)));
return visible;
});
  • Owned entities are never culled — a policy can’t hide a client’s own pawn.
  • Re-entering is seamless: the respawn carries the entity’s current state.
  • radiusInterest reads Transform.position by default (override with { position }); placeless entities are always relevant, and it fails open while a connection owns no positioned entity yet.
  • Installing, replacing, or removing the policy mid-session is safe — the next tick reconciles every connection’s ghost set.

Without prediction, your own pawn only moves after a full server round trip. Enable prediction by handing the client the same input-to-state function the server’s gameplay runs — one function, both ends, no duplicated movement rules:

// The ONE movement rule, shared by both ends.
function applyMove(world, entity, actions, dt) {
const move = actions.move;
if (!move) return;
const pos = world.tryGet(entity, NetPos);
pos.x += move.x * SPEED * dt;
pos.y += move.y * SPEED * dt;
world.set(entity, NetPos, pos);
}
// SERVER gameplay (FixedUpdate): per-tick input via tickInputOf.
const input = server.tickInputOf(repl.owner);
if (input) applyMove(world, e, input.actions, time.fixedDelta);
// CLIENT: enable prediction; sendInput once per fixed tick.
await clientNet.connect(clientEnd, {
prediction: { apply: applyMove },
});
clientNet.client.sendInput({ move: { x: 1, y: 0 } }); // pawn moves NOW

How it works: sendInput applies the command to your owned entities immediately and keeps it in a pending buffer; the server consumes commands exactly one per fixed tick (tickInputOf — use it instead of inputOf for predicted gameplay) and acknowledges the consumed seq; every fixed tick the client rebuilds each owned entity as last authoritative state ⊕ replay of unacknowledged commands. Server-side corrections (walls, knockback) therefore always win, and mispredictions can never accumulate — even fields the server never re-sends snap back. Owned entities bypass snapshot interpolation.

  • Send an input every fixed tick (an idle { move: {x:0,y:0} } counts) — the server repeats the last command when the queue runs dry, so going silent means “keep doing that”, not “stop”.
  • Assign Replicated.owner at spawn — ownership is part of the spawn payload.
  • apply must depend only on world state + actions + dt: it re-runs during reconciliation.
  • Smoothing: prediction: { apply, smoothing: { halfLife: 0.08 } } eases corrections out (the visual error halves every halfLife seconds) instead of hard-snapping; add maxError so a genuine teleport still snaps. Purely presentational — the simulation state can’t drift.
  • Use NetChannel for anything beyond a trivial socket — routed types and RPC beat a giant 'message' handler switch.
  • Set an RPC timeout so a lost reply rejects instead of hanging forever.
  • Push received state into components via Commands so gameplay stays in ECS.
  • Queue-before-open is automatic — you can send right after connect().
  • Gate authority in systems — check app.getResource(Net).role ('server' / 'client' / 'offline') so the same code runs as server, client, or offline single-player.