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.
Connect & exchange messages
Section titled “Connect & exchange messages”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. |
Typed messages & RPC — NetChannel
Section titled “Typed messages & RPC — NetChannel”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 idconst 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 clientconst 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 lagclientNet.client?.sendInput({ move: { x: 1, y: 0 } }); // per-tick input uplink- Authority: only the server simulates; clients see replicated, interpolated
state (
NetGhosttags the proxies). The handshake refuses a protocol/ABI/schema mismatch fail-loud, so drifted builds never desync silently. - Ownership:
Replicated.ownerroutes a connection’ssendInputcommands 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.
Interest management — who sees what
Section titled “Interest management — who sees what”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.
radiusInterestreadsTransform.positionby 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.
Client prediction — zero-latency input
Section titled “Client prediction — zero-latency input”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 NOWHow 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.ownerat spawn — ownership is part of the spawn payload. applymust 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 everyhalfLifeseconds) instead of hard-snapping; addmaxErrorso a genuine teleport still snaps. Purely presentational — the simulation state can’t drift.
Best practices
Section titled “Best practices”- Use
NetChannelfor 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
Commandsso gameplay stays in ECS. - Queue-before-open is automatic — you can
sendright afterconnect(). - Gate authority in systems — check
app.getResource(Net).role('server'/'client'/'offline') so the same code runs as server, client, or offline single-player.
See also
Section titled “See also”- WeChat MiniGame — sockets use
wx.connectSocketthere. - Saving & Loading — persist state locally between sessions.