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 — the schema it compares is field names and their wire shapes, since a field defaulting to 0 on one end and false on 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 no replicated fields 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().attachConnection and Net.connect take a ReliableOrderedTransport — every built-in socket is one; a transport you write yourself declares delivery: 'reliable-ordered', and one that cannot make that promise is a compile error rather than a desync.

  • 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'. Install what the project registered at module scope with flushPendingRegistrations(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.owner is 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.

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 the composed world position of Transform 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.

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 worldPosition itself does not — that field is the composition’s output, not an 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 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.