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 — the schema it compares is field names and their wire shapes, since a field defaulting to0on one end andfalseon the other agrees on the name and disagrees on the byte count. -
A ghost is built from a declared archetype, not from the server’s components. A spawn carries three things and they are different contracts: identity (
netId,owner, parent, name), the construction key, and a baseline of declared replication fields. Nothing else crosses — a component with noreplicatedfields never reaches a client, and neither do a component’s undeclared fields.So anything a proxy needs in order to exist has to be said out loud:
import { registerReplicationArchetype, Replicated, Sprite } from 'esengine';registerReplicationArchetype('pawn', (world, entity) => {world.insert(entity, Sprite, { size: { x: 36, y: 36 }, layer: 2 });});// On the server, name it when the entity starts replicating.world.insert(e, Replicated, { owner: connectionId, archetype: 'pawn' });The archetype runs first and the authority’s baseline lands on top, so an entity that leaves interest and comes back arrives at the current state rather than at a fresh default. A client that has no builder for the key refuses the spawn rather than showing half an entity.
-
Structure replicates, not just values: adding a replicated component to a live entity sends its full state; removing one removes it from every ghost. Spawn → component add → updates → component remove → despawn all cross the wire, and arrive in the order the authority sent them.
-
The link must be reliable and ordered: the client applies what arrives in arrival order, because that is the authority’s order.
startServer().attachConnectionandNet.connecttake aReliableOrderedTransport— every built-in socket is one; a transport you write yourself declaresdelivery: 'reliable-ordered', and one that cannot make that promise is a compile error rather than a desync. -
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'. Install what the project registered at module scope withflushPendingRegistrations(app), the same door the shipped web runtime uses, and the authority runs the project’s own systems rather than a copy of them. -
Reconnect is yours for now:
Replicated.owneris a connection id, and a player who reconnects gets a new one. Nothing in the SDK carries an identity across a dropped socket, so a stable player id, a session token and a grace window belong to your game.
See the Multiplayer Arena example for the full pattern — replication, per-tick input, and client prediction — in ~130 lines. Its server/ directory is the dedicated-server half: the same project, headless, behind a real WebSocket.
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.
radiusInterestreads the composed world position ofTransformby 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.
Scaling it: radiusInterestProvider
Section titled “Scaling it: radiusInterestProvider”A policy is handed the population per connection, so whatever it reads it reads once per connection. A provider prepares one spatial index per sample and answers every connection from it:
import { radiusInterestProvider } from 'esengine';
server.setInterestProvider(radiusInterestProvider(800));It answers exactly what the policy answers, and it has two modes — a difference in capability, not a fallback:
| reader | what it does | why |
|---|---|---|
the default Transform |
keeps its index between samples and moves only the entities that actually moved | the engine reports which world transforms a composition changed |
a custom position |
rebuilds the index every sample | nothing can know when an arbitrary function would answer differently |
At 100,000 entities and 32 connections the kept index costs 18% of one core against 195% for rebuilding it every sample, and reads 668 positions per sample instead of 100,000. With nothing moving it reads none.
And when nothing entered, left or moved, no connection is queried at all. The kept index says it is the same snapshot it was; a connection whose owned entities have not changed hands either is already holding the answer, so it keeps it. A stationary sample at that scale costs microseconds instead of a fifth of a core.
That is about visibility, not about traffic: a field changing on an entity you can see still reaches you, whether or not your view moved.
Two things follow from the kept index, and neither is a limitation you can work around by writing the reader differently:
- If you supply
position, you get the per-sample rebuild. That is the correct answer for an arbitrary function, and it is still far cheaper than a policy. - Physics-driven and parent-driven movement both reach it, because both go
through the same composition. A game that writes
worldPositionitself does not — that field is the composition’s output, not an input.
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.