Skip to main content

xsync

xsync is xnew's network synchronization layer. The server is the single source of truth, and clients hold replicas of it.

The same component function runs on both Node (server) and the browser (client); xsync.server / xsync.client only decide which environment a block runs in. In practice, game logic goes on the server side and rendering plus input goes on the client side.

import { xnew, xsync } from '@mulsense/xnew';

export function Player(unit) {
const state = xsync.state({ x: 0, y: 0 }); // shared state

xsync.server(() => { // runs on Node only
unit.on('update', () => { state.x += 1; });
});

xsync.client(() => { // runs in the browser only
const el = xnew.nest('<div class="absolute w-4 h-4 bg-blue-500">');
unit.on('update', () => { el.style.left = `${state.x}px`; });
});
}

The big picture​

server (Node) client (browser)
┌───────────────────────────┐ ┌───────────────────────────┐
│ xsync.boot({ io, room }) │ │ xsync.boot({ io, client, │
│ Game │ │ room }) │
│ └ World │ ── 'sync' ────▶ │ Game │
│ ├ Player (state) │ state channel │ └ World │
│ └ Player (state) │ on change only │ ├ Player (replica) │
│ │ │ └ Player (replica) │
│ unit.on('-move') │ ◀── 'emitToServer'│ xsync.emitToServer('-move')│
└───────────────────────────┘ messages └───────────────────────────┘
  • State is one-way. It only flows from server to client.
  • Client input travels as a message (xsync.emitToServer); the server is what mutates state.
  • There is no client-to-client channel. To broadcast, relay through xsync.emitToClients from a server handler.

The three channels​

These are the socket event names xsync reserves — never use them as application event types.

ChannelDirectionWire eventRole
Stateserver → clientsyncSnapshot of the sync tree. Sent only when it changed
Rosterserver → clientstatusRoom membership list
Messageclient → serveremitToServerFire an arbitrary event on the server
Messageserver → clientemitToClientsFire an arbitrary event on the clients
Lifecyclebothconnect / disconnect / notfoundDelivered to units as sync.connect and friends

Examples​

  • examples/1_xnew/sync/multiplay/ — lobby / rooms, scene sync, chat
  • examples/1_xnew/sync/hidden-info/ — secret information via xsync.visibility

Environment split — xsync.server / xsync.client​

The same component function runs on both the server and the client. The environment is detected automatically: Node is server, a browser (where window.document exists) is client.

function Player(unit) {
xsync.server(() => { /* runs on Node only */ });
xsync.client(() => { /* runs in the browser only */ });
}
  • They behave like xnew.extend: the object the callback returns becomes defines on the unit.
  • They are init-only (callable during the synchronous body of the component function).
  • On the other side the callback never runs at all. Any browser-only API must live inside xsync.client.
Importing browser-only libraries

Never statically import an addon (xpixi, xthree, …) or a browser-only library from a game.js shared by server and client. import resolves before xsync.client ever gets to branch, so Node loads it too and crashes. Hand it over dynamically instead — for example, put it on window from the browser entry (index.js).


Booting — xsync.boot​

Creates the root of a sync tree. The arguments differ slightly between the two sides.

// server
xsync.boot({ io, room }, Game);

// client
xsync.boot({ io, client, room }, Game);
PropertyTypeDescription
ioanyOn the server, the socket.io Server instance. On the client, the factory that creates a socket (window.io)
room{ id, name, count }The room to join. id separates socket.io rooms
client{ name }Client side only. Display name; travels in the handshake query

On the client, boot creates and owns the socket itself. Callers must not create one (that would open a second connection). When the unit boot created is finalized, the socket is disconnected automatically.

// what boot does internally (client)
io({ query: { roomId: room.id, clientName: client?.name ?? '' }, forceNew: true });

boot passes its third and later arguments straight through as the root unit's components. Lifecycle listeners must live inside the root, so appending a function is the usual shape.

xsync.boot({ io, client, room }, Game, (u) => {
u.on('sync.connect', ({ id }) => { /* ... */ });
u.on('sync.notfound', () => unit.change(Lobby));
});
Lobbies and rooms are not part of xsync

xsync provides only the sync facade — boot, state, emit and so on. "Gathering place" wiring (room listings, room creation) is assembled in your application with socket.io directly; examples/1_xnew/sync/multiplay/server.js is a worked example.

xsync.session​

Returns session information for the current sync root. Available under that root only.

xsync.session.room // { id, name, count } the room you are in
xsync.session.clients // [{ id, name }, ...] room members
xsync.session.myself // { id, name } yourself (client only)
  • clients is readable on both sides. The server updates it as connections are accepted; the client receives it over the status channel.
  • myself is client-only — reading it on the server throws, since the server has no "self".
  • When the roster changes, sync.statusupdate fires on both sides.

Shared state​

The sync tree​

Below the server's boot root sits an ordinary unit tree. Only units created from a component registered in their parent with xsync.register count as sync nodes and get replicated to clients.

server client
Game ← boot root Game ← boot root
├ (div) ← not a sync node (not replicated)
└ World ← registered ─────────▶ World ← replica
└ Player ← registered ─────────▶ └ Player ← replica
  • Unregistered units are passed through: if their children are sync nodes, those children attach to the nearest sync node above. Wrapping DOM does not disturb the tree shape.
  • Each sync node gets an id starting at 1, and keeps it for the life of the unit.

xsync.register​

Declares which components may be synced as children of this unit. The client uses this table to look up a component by the name that arrives and build the replica. A name that is not registered is ignored.

export function World(unit) {
xsync.register({ Player }); // Player may be synced as a child of World

xsync.server(() => {
xnew(Player, { clientId, slot }); // created on the server → replicated to every client
});
}
  • Init-only (calling it outside the invoked phase throws).
  • The keys are the names that travel on the wire. Register the same key on both sides — sharing one file usually makes this automatic.

xsync.state​

Returns the state this sync node shares. It is meant to be written on the server and read on the client.

export function Player(unit, { slot = '' } = {}) {
const state = xsync.state({ x: 0, y: 0, slot });

xsync.server(() => {
unit.on('update', () => { state.x += 1; }); // the server writes
});

xsync.client(() => {
unit.on('update', () => { el.style.left = `${state.x}px`; }); // the client reads
});
}
  • It returns the same object for the unit every time — repeat calls hand back the same reference.
  • The initial values are applied only to keys that don't exist yet. A client replica is constructed already holding the server's values, so writing xsync.state({ x: 0 }) never clobbers the server value with 0.
  • Values travel as JSON. Functions, DOM elements and class instances cannot be stored (numbers, strings, booleans, arrays and plain objects only).
  • Writing state on the client is overwritten as soon as the next server value arrives. Send change requests as events instead.

Replica creation and disposal​

The client reconciles each arriving tree.

On the serverOn the client
A sync node is createdLook the component up in the register table and create a replica unit
State changedUpdate the existing replica's state in place (the unit is not rebuilt)
A sync node was finalizedFinalize the matching replica
A node became invisible (see visibility below)Treated as a deletion (the replica is finalized)

State updates rewrite keys rather than swapping the object, so it is safe to capture the reference from xsync.state in a closure.

props are a server-side thing

The props in xnew(Player, { clientId, slot }) reach only the unit created on the server. Replicas are constructed without props. Anything the client also needs must be carried in state — that is why the example above puts slot there.


Events​

State (xsync.state) flows only from server to client. Anything going the other way, and any transient notification you don't want to keep in state (chat, a sound trigger), travels as an event.

Sending​

The method names say which side the event fires on, not which side you call from.

MethodFires onCallable from
xsync.emitToServer(type, props)the serverboth
xsync.emitToClients(type, props, ids?)the clientsserver only
// client → server
xsync.emitToServer('-move', { vector: { x: 1, y: 0 } });

// server → every client (including the sender)
xsync.emitToClients('chat', { id, text });

// server → specific clients only
xsync.emitToClients('deal', { card }, [clientId]);
  • Calling emitToServer on the server never touches the wire; it behaves exactly like a local xnew.emit.
  • Calling emitToClients on a client throws. To broadcast something a client originated, receive it in a server handler and relay it.
// the standard shape: the server receives a client's message and hands it to everyone
xsync.server(() => {
unit.on('chat', ({ id, text }) => xsync.emitToClients('chat', { id, text }));
});
xsync.client(() => {
sendButton.on('click', () => xsync.emitToServer('chat', { text: input.value }));
unit.on('chat', ({ id, text }) => appendLine(id, text));
});

Receiving​

Everything is received with an ordinary unit.on.

unit.on('chat', ({ id, text }) => { /* ... */ });

The first argument carries sender information alongside your payload.

FieldContents
idThe sender client's socket id. undefined for a server-originated emitToClients
othersThe contents of props, spread in

When the server relays a message and you want the original sender preserved, put it in props explicitly — that is what { id, text } above is doing.

Scoping with the - prefix​

When a component exists many times in a room (one Player per participant), you need to narrow the destination.

Type nameReaches
'-move'Listeners on the same sync node only (the one replica ↔ server pair)
'chat' (no prefix)Every unit under that root listening for the type
// Player: deliver this client's input only to "its own" Player on the server
xsync.client(() => {
unit.on('window.keydown.wasd', ({ vector }) => xsync.emitToServer('-move', { vector }));
});
xsync.server(() => {
unit.on('-move', ({ vector }) => { vel.x = Math.sign(vector.x); }); // other players' moves never arrive
});

- works when sender and receiver belong to the same sync node (the same id). A replica carries the id of its server-side node, so the pairing is automatic.

Reserved names

sync, status, emitToServer and emitToClients are reserved wire event names. Do not use them as application types.

Lifecycle events​

These fire automatically under the boot root. Listeners must live inside the boot root.

EventSideMeaning
sync.connectbothThe participant { id } connected
sync.disconnectbothThe participant { id } disconnected
sync.notfoundclientThe room being joined did not exist (own connection failure only)
sync.statusupdatebothThe roster (xsync.session.clients) changed
sync.updateclient onlyAn incoming state update has been applied

Tell yourself from others by comparing ids.

unit.on('sync.connect', ({ id }) => {
if (id === xsync.session.myself.id) { /* I joined */ }
else { /* somebody else joined */ }
});

Your own events come from your own socket; other members' events are relayed by the server (the sender is excluded, so nothing fires twice).

sync.update fires exactly once at the moment the server's state actually changed and has been applied. Use it for rebuild-style UI.

let dirty = true;
unit.on('sync.update', () => { dirty = true; });
unit.on('update', () => {
if (dirty) { rebuild(); dirty = false; } // rebuild inside the tick
});

Per-frame following (physics objects and the like) should read state in on('update') without waiting for sync.update.

Where xnew.scope is required​

Socket callbacks run outside xnew's tick. Every event xsync delivers is already handled internally, but if your application attaches its own socket handlers, or you call xsync.emitToServer from an addon event (a pixi pointerdown, a three raycast hit), wrap it in xnew.scope. Without it Unit.current is wrong and you get no socket bound to this root.

socket.on('roomcreated', xnew.scope((payload) => xnew.emit('-roomcreated', payload)));

Visibility — xsync.visibility​

Some state is meant for one client only — a hand of cards, a secret role. xsync.visibility declares which clients a sync node is sent to.

export function PlayerView(unit, { ownerId = '' } = {}) {
const state = xsync.state({ ownerId, secret: 0, revealed: false });

xsync.server(() => {
state.secret = 1 + Math.floor(Math.random() * 100);
xsync.visibility((clientId) => state.revealed || clientId === state.ownerId);
});

xsync.client(() => {
xnew('<p>', `your number: ${state.secret}`);
});
}
xsync.visibility(predicate); // (clientId: string) => boolean
xsync.visibility(null); // back to public
  • Sync nodes are public by default. A node that never declares visibility reaches everyone.
  • A client the predicate rejects sees the node as if it did not exist: nothing arrives, so no replica is created (an existing one is finalized).
  • The whole subtree is excluded with it. Sending a child whose parent is hidden would leave the parent id dangling.
  • The predicate is re-evaluated on every capture, so closing over a flag and flipping it is all a dynamic reveal takes (revealed above).

Why it must be declared on the server​

visibility decides what goes on the wire. An excluded client is never sent that state at all.

server ── captureStateTree('clientA') ──▶ clientA … only A's PlayerView
── captureStateTree('clientB') ──▶ clientB … only B's PlayerView

Hiding on the client — simply not drawing it — protects nothing, because DevTools shows the payload. Always declare visibility inside the xsync.server block.

The typical shape is one node per client, declared visible only to its owner (a working example lives in examples/1_xnew/sync/hidden-info/).

xsync.server(() => {
unit.on('sync.connect', ({ id }) => xnew(PlayerView, { key: id, ownerId: id }));
unit.on('sync.disconnect', ({ id }) => xnew.find(PlayerView, { key: id })[0]?.finalize());
});

Update timing and bandwidth​

This section answers the obvious question: "it runs at 60Hz — does the server push state every frame?"

Short answer: the tick is 60Hz, but state is only sent when it changed.

The tick is 60Hz​

The xnew engine holds a single 60fps ticker, and every unit's update event fires from it.

  • Browser: requestAnimationFrame
  • Node: setTimeout

Both use an absolute schedule (the target time advances by += 1000/60), so the fractional remainder carries over and the average rate holds at 60Hz. The server (Node) and the client (browser) run on this same mechanism.

The state channel sends on change only​

Every tick, the server's boot root does this:

each tick, for every connected client:
1. walk the sync tree and build that client's projection (applying visibility)
2. serialize it to JSON
3. compare it with the string last sent to that client
4. emit 'sync' only if they differ

Which means:

  • If nothing is moving, nothing goes on the wire. During a title screen or while waiting for a turn, bandwidth is zero.
  • If one value changes, the whole projection for that client is sent. There are no field-level deltas; the diffing happens on the client.
  • Put the other way round: a sync arriving means something changed. The client fires sync.update off the back of it.

The client keeps the same comparison (against the last tree it applied), so a server that re-sends identical content will not fire sync.update twice.

What counts as a change​

The test is exact string equality of the whole serialized projection. Adding or removing a node, reordering, or a single field of one node's state changing — all of them count.

Two practical consequences follow.

1. Tiny changes still mean a send every frame

If a physics simulation keeps nudging a coordinate, the object looks stationary but the JSON differs every frame, so it keeps sending at 60Hz. To make the wire go quiet at rest, round before storing.

unit.on('update', () => {
state.x = Math.round(body.position.x * 10) / 10; // snap to 0.1 → sending stops when it settles
});

2. Don't put values in state that don't need to travel

The comparison and the send are per tree, not per node. A frequently-changing internal value in state raises the send rate for the whole room. Keep server-only values in ordinary local variables.

Messages and lifecycle events ignore the tick​

xsync.emitToServer / xsync.emitToClients are sent the moment you call them; nothing batches them onto a tick. Input latency is therefore unaffected by the tick — a keypress goes to the server immediately.

sync.connect, sync.disconnect and status (the roster) are likewise handled as socket events, immediately.

Bandwidth estimate​

One node serializes to roughly this:

{"id":3,"name":"Player","parent":2,"state":{"x":112,"y":64,"clientId":"aBc123","slot":"p1"}}

About 90 bytes. For a two-player game with four sync nodes (World + Player×2 + Board), one sync is roughly 350–400 bytes. In the worst case, with both players moving so every tick differs:

400 bytes × 60Hz ≈ 24 KB/s per client

and it drops to zero once movement stops. This is an estimate — measure the real numbers in the DevTools Network panel (WS frames).

CPU cost is linear in client count​

Sending happens only on change, but building the projection and serializing it happens every tick, once per client (because visibility may return a different result per client).

server load per tick ≈ O(connected clients × sync nodes)

For rooms of a handful to a dozen participants this is a non-issue; if you design for large rooms, keeping the node count and state size small is what pays off.

Client-side render timing​

The client's update is driven by rAF, so it is independent of when state arrives.

  • Arrivals are applied to state as socket events, whenever they land.
  • Rendering happens on the 60Hz update, reading whatever state is current at that moment.

There is no interpolation or prediction (no client-side prediction, no lag compensation) built in. The client draws the last state it received. With a server ticking at 60Hz that is usually smooth enough, but poor connections will show stutter. Interpolate the state values yourself in the render path if you need it.

While a tab is hidden

The browser suspends requestAnimationFrame in a hidden tab, so the client's update stops. Reception continues and state stays current, so returning to the tab resumes from the latest state. The server's tick (Node) is unaffected.