Multiplayer Games
Clayground.Network gives you peer-to-peer connectivity out of the box, but a
smooth real-time game needs the right patterns on top of it. This guide
covers what to send on which channel, how to render remote entities, and how
to diagnose sync problems.
Two channels, two jobs
Every peer pair is connected by two WebRTC data channels:
- Message channel (
broadcast,sendTo) - reliable and ordered, like TCP. Everything sent arrives, in order, eventually. - State channel (
broadcastState) - unordered, lost packets are not retransmitted, and every update carries a sequence number so receivers silently drop anything older than what they already have.
The rule of thumb:
| Data | Channel | Why |
|---|---|---|
| Positions, velocities, aim angles | broadcastState |
Only the newest value matters; a lost packet is obsolete 50 ms later anyway |
| Attacks, pickups, deaths, chat | broadcast |
Missing one changes the game outcome |
| Level changes, seeds, game start | broadcast |
Everyone must agree on these |
Never send continuous state over the reliable channel: one lost packet then delays every newer update behind it (head-of-line blocking) and the lag snowballs on a lossy connection - the game feels increasingly “behind” and never recovers.
State snapshots, not deltas
Broadcast complete little snapshots at a fixed rate (15-20 Hz is plenty for a 2D action game):
Timer {
interval: 50; repeat: true
running: network.connected && player !== null
onTriggered: network.broadcastState({
x: player.xWu, y: player.yWu,
a: player.facingAngle,
h: player.hp
})
}
Because updates can be lost, every snapshot must be self-contained - never send “moved 0.2 to the left”.
Rendering remote entities: StateInterpolator
Raw 20 Hz updates rendered directly look jittery, and smoothing them with
Behavior animations fights the property system (see below). Use
StateInterpolator: it buffers timestamped snapshots and renders the entity
a small constant delay in the past, always blending between two known
states - the standard technique used by fast-paced multiplayer games.
// Remote avatar
PhysicsItem {
id: avatar
function pushState(data) { sync.push(data) }
StateInterpolator {
id: sync
delayMs: 120 // >= 2x the sender's update interval
angleKeys: ["a"] // degrees, interpolated via shortest arc
onUpdated: {
avatar.xWu = value.x
avatar.yWu = value.y
avatar.facingAngle = value.a
}
}
}
// Feed it from the network
Network {
onStateReceived: (from, data) => remoteAvatars[from]?.pushState(data)
}
Call sync.reset() when the entity teleports (level change, respawn) so it
snaps instead of gliding across the map.
Do not use Behavior on xWu/yWu. Beyond being the wrong model for
network smoothing (every update restarts an animation from wherever it is -
rubber-banding), physics world-unit properties are bidirectionally synced
with pixel coordinates, which historically caused animations on them to
stall entirely (clayground issue #139).
Star topology: the host is the authority
In the default Star topology, joiners connect only to the host; the host
relays traffic between joiners (autoRelay). Consequences:
nodeslists all other participants on every node (the host propagates the roster). Spawn one remote avatar per entry, keyed by node id - state updates arrive tagged with the original sender’s id even when relayed.- Joiner-to-joiner latency is two hops. Keep that in mind for hit judgments; favor letting each client be authoritative over things that only affect itself (own position, own attacks).
- Make the host the referee for anything global: level transitions, match
start, enemy spawns. A joiner that triggers a global event asks the host
(
broadcastan intent) and the host answers with the authoritative event. Never let each client apply a global change locally on its own - with procedural content the worlds silently diverge.
// Level transitions, host-authoritative
onExitReached: {
if (network.isHost) advanceLevel() // broadcasts levelChange
else network.broadcast({type: "exitReached"}) // host will answer
}
onMessageReceived: (from, data) => {
if (data.type === "levelChange") applyLevel(data.levelIndex)
if (data.type === "exitReached" && network.isHost) advanceLevel()
}
Shared procedural content needs a shared seed: the host picks it and sends it with the start message; every client generates the identical world from it.
Diagnosing sync problems
Drop a NetworkMonitor into your UI during development:
NetworkMonitor {
network: gameNetwork
anchors.bottom: parent.bottom; anchors.right: parent.right
}
Per node it shows round-trip time, incoming state rate, the age of the newest state and how many stale updates were dropped. How to read it:
- age growing while rate is 0 - the sender stopped broadcasting (or
left); check their
runningcondition. - high drop count - the network reorders/loses heavily; your game still works (that is the design), but consider a lower send rate.
[fallback]marker - the lossy state channel didn’t negotiate and state travels over the reliable channel; expect lag under loss.- rtt high but rate fine - pure latency; increase
delayMson your interpolators rather than fighting jitter.
The same numbers are available programmatically via network.syncStats,
network.peerStats and network.stateAgeMs(nodeId) - including from the
Inspector, which makes
multi-instance multiplayer sessions scriptable end to end (that is exactly
how plugins/clay_network/tests/gym verifies all of the above in CI).
Checklist
- Positions via
broadcastState, events viabroadcast - Snapshots self-contained, 15-20 Hz
- Remote entities rendered through
StateInterpolator(noBehavior) reset()on teleports and level changes- Host authoritative for global events; shared seed for procedural content
- Remote avatars spawned per
nodesentry, keyed by node id NetworkMonitorvisible in dev builds