Multiplayer

Net is server-authoritative multiplayer for 3D games over UDP. One bundle runs every role — offline, host, client and dedicated server. The server simulates. Each client sends its input every tick, predicts its own player and shows everyone else interpolated. Replicated marks a node as a networked entity, NetEntity is that entity's record on each machine, and NetPlayer is one connected player. Two other networking APIs cover different jobs: WebSocket is a plain socket to a web server (network.md), and server functions are calls to the app's backend (server-functions.md).

At a glance

TypeScript
// One bundle, every role: `lecodes serve` = the dedicated server, `lecodes desktop run --connect
// 127.0.0.1:40000` = a client, plain `lecodes desktop run` = offline (the same game, one player).
const scene = new Scene({ skybox: "#8fb2d9" })
scene.add(Light.sun({ direction: [-0.4, -0.9, -0.35] }))
scene.camera.position = [0, 12, 14]
scene.camera.lookAt([0, 0, 0])

// The shared vocabulary: this tick's input, and a replicated entity type.
const Move = Net.input({ x: 0, z: 0 })
const PlayerKind = Net.kind("player", { pos: [0, 0, 0] }, {
  transform: "pos",                          // state.pos mirrors node.position
  client: (rep, scene) => {                  // a client births its proxy when the server spawns one
    const node = makePlayer(rep.isMine)
    scene?.add(node)
    return node
  },
})

function makePlayer(mine: boolean): Node {
  return Mesh.box({ size: 1, material: Material.lit({ color: mine ? "#e0743c" : "#4f7cff" }) })
    .aspect(Walk)
}

// Input: once per tick, on every machine that has a local player.
class Controls extends System<"controls"> {
  updateFixed() {
    if (Net.role === "server") return        // a dedicated server has no player of its own
    const x = (Input.key("KeyD") ? 1 : 0) - (Input.key("KeyA") ? 1 : 0)
    const z = (Input.key("KeyS") ? 1 : 0) - (Input.key("KeyW") ? 1 : 0)
    Net.setInput(Move, { x, z })
  }
}
scene.system(Controls)

// The simulation: the same code on the server (every player) and on a client (its own player).
class Walk extends Aspect<"walk", Node> {
  static after = [Controls]
  updateFixed(dt: number) {
    const rep = this.node.get(Replicated)
    if (!rep?.simulated) return              // someone else's player: the SDK moves the proxy
    const move = rep.input(Move)
    this.node.x += move.x * 4 * dt
    this.node.z += move.z * 4 * dt
  }
}

// The server decides what exists: a node with Replicated exists on every client.
const spawn = (p: NetPlayer) => scene.add(makePlayer(p.isMe).aspect(Replicated, { kind: PlayerKind, owner: p }))
Net.on("join", (p) => { if (Net.isServer) spawn(p) })

scene.open()
const launch = Net.launch
if (launch?.role === "server") Net.listen({ port: launch.port, maxClients: launch.maxClients })
else if (launch?.role === "client") Net.connect({ address: launch.address }, { name: "Guest" })
else spawn(Net.me)                           // offline
TypeScript
lecodes serve                                   # dedicated server, windowless (port 40000, 16 slots)
lecodes serve --connect 127.0.0.1:40000         # a headless client: a bot, a scripted test player
lecodes desktop run --connect 127.0.0.1:40000   # a client window
lecodes desktop run                             # offline

A client moves its own box at once: it predicts with the same Walk code the server runs, so the two agree tick for tick. Other players' boxes glide about 100 ms behind.

Roles and sessions

Net.role how isServer isClient Net.me
"offline" the default true true a local player, id 1
"host" Net.listen() in a game that keeps its own player true true the host's player, id 1
"server" Net.listen() on an exe launched with --server true false null
"client" Net.connect() false true the id the server assigned

Guard the rules with Net.isServer and the presentation with Net.isClient. Offline satisfies both, so a game written for multiplayer also runs single-player unchanged.

TypeScript
Net.launch: NetLaunch | null     // how the exe was started; null = no multiplayer flag
Net.listen(opts?): boolean       // serve; false + an "error" event when it can't
Net.connect({ address }, opts?): Promise<void>   // resolves once the server welcomed us
Net.disconnect(): void
Net.role: NetRole                // "offline" | "host" | "client" | "server"
Net.status: NetStatus            // "idle" | "connecting" | "online" | "error"
Net.isServer / Net.isClient / Net.supported: boolean
  • Net.launch reports the host's command-line flags. It never connects anything on its own.
    • --server [--port N] [--max-clients N] gives { role: "server", address: "", port, maxClients }.
    • --connect ip:port gives { role: "client", address, port: 40000, maxClients: 16 }.
    • lecodes serve and lecodes desktop run --server | --connect pass these flags.
  • Net.listen options:
    • port: default 40000.
    • maxClients: default 16, capped at 64.
    • name: this machine's player name.
    • dedicated: whether there is no local player. Defaults to true when the exe was launched with --server, false otherwise. Pass dedicated: false to make a --server window a host.
    • accept: see the handshake below.
  • From offline, Net.listen makes this machine the host, and the offline Net.me stays as the host's player.
  • Net.connect takes an IP address and port, "192.168.1.10:40000". A host name is refused at once: bad address 'localhost:40000'. Options: name (default "Player") and hello (below).
  • Net.disconnect() on a client goes offline and fires disconnect ("disconnected"). On a host or server it drops every player (a leave each, reason "server closed") and goes offline.
  • Stopping the server's process (Ctrl+C on lecodes serve) ends the session: every client gets disconnect with "disconnected by server".
TypeScript
Net.on("connect", () => …)             // client: welcomed · host/server: listening (fires inside listen())
Net.on("join", (player) => …)           // someone joined (on a client: another player)
Net.on("leave", (player, reason) => …)  // e.g. "disconnected", "timed out", "kicked", "server closed"
Net.on("disconnect", (reason) => …)     // client: the session ended ("disconnected by server", …)
Net.on("error", (message) => …)         // a connect or listen that failed
Net.on("reject", (reason) => …)         // see the note below
Net.off(event, cb)                      // on / off return Net

When a connect or listen fails:

cause what you get
nothing listens at the address after about 10 s, connect() rejects and error fires: "connection request timed out"
the server is full at once, connect() rejects and error fires: "connection denied"
a host name, a malformed address at once, connect() rejects: "bad address '…'"
the port is taken listen() returns false and error fires: "could not listen on port N"
the host has no transport connect() rejects, or listen() returns false and error fires: "multiplayer is not supported on this host"

After a timed-out, denied or unbindable attempt, status is "error". In every case the role is back to "offline".

The handshake — hello and accept

TypeScript
// server
Net.listen({
  port: 40000,
  accept: (hello, who) => (hello as { build?: number })?.build === 7 ? null : `${who.name}: update the game`,
})
// client
Net.connect({ address: "192.168.1.10:40000" }, { name: "Den", hello: { build: 7 } })
  .catch((e) => console.log("could not join:", e.message))

A client's hello is any JSON value, such as a build number or a token. The server's accept(hello, { name }) returns a reason string to refuse the client, or nothing to let it in. An accepted player keeps the value as player.hello, which exists on the server only. The SDK also refuses a client on its own in two cases: a different SDK protocol, or Net.kind / Net.input declarations that differ from the server's (in practice, a different bundle).

Not implemented

the refusal reason never reaches the client. The server queues the reason, then drops the connection before the queued message is sent. So the client's connect() rejects with "disconnected by server", and reject never fires. player.kick(reason) works the same way:

  • the kicked client gets disconnect with "disconnected by server";
  • the server's leave event and the other clients see reason "kicked";
  • your reason text reaches no one.

Players

TypeScript
Net.me: NetPlayer                      // this machine's player; null on a dedicated server
Net.players: readonly NetPlayer[]      // everyone in the session, Net.me included
Net.player(id): NetPlayer | undefined

player.id: number                      // unique in the session, assigned by the server
player.name: string                    // what connect / listen was given — not unique
player.isMe: boolean
player.connectedAt: number             // Date.now() ms when this machine learned of the player
player.hello: unknown                  // server only: what the player's connect() carried
player.data: any                       // a local bag for game facts — NOT replicated
player.rtt / player.loss: number       // ms / percent
player.send(msg, data)                 // server → this player (see Messages)
player.kick(reason?)                   // server: drop this player (see the note above)
player.on("leave", (player, reason) => …) / player.off("leave", cb)
Net.rtt: number                        // client: our round trip to the server in ms; 0 elsewhere
  • On a host, the host's own player is id 1. On a dedicated server, clients count from 1.
  • rtt and loss on the server measure the link to that player. On a client, every player reports this machine's own link to the server. The host's own player reports 0.
Note

on a client, read Net.me and Net.players only after connect() resolves (or in the connect event). A read made while offline or connecting creates a local placeholder player, and it currently stays in Net.players after the welcome as a stale entry. The same goes for entities spawned while offline: they stay after connect(), and their ids collide with the server's entities (those proxies are never created). Destroy them before a client joins.

Messages

TypeScript
const Chat = Net.message<{ text: string }>("chat")          // reliable, ordered
const Ping = Net.message<{ at: number }>("ping", { unreliable: true })

Net.send(msg, data)                          // → the server, from a machine with a player
Net.broadcast(msg, data, { except? })        // server → every player (the host's own included)
player.send(msg, data)                       // server → one player
Net.on(msg, (data, from) => …)               // from = the sending player on the server; null = sent by the server
Net.off(msg, cb)
TypeScript
Net.on(Chat, (msg, from) => {
  if (from) Net.broadcast(Chat, { text: `${from.name}: ${msg.text}` })   // the server: relay to everyone
  else console.log(msg.text)                                            // a message from the server
})
Net.send(Chat, { text: "gg" })

This relay works unchanged in every role. On a host or offline, the local player's message is handled in-process with from = Net.me, and the broadcast comes back with from = null.

  • The payload is JSON; the type parameter exists only in TypeScript. The name is the wire id, and names starting with $ are reserved (throws).
  • A local delivery (offline, or the host's own player) runs asynchronously, after the current code, never inside send.
  • Calling from the wrong side throws. Net.send is not allowed on a dedicated server, which addresses players instead. Net.broadcast and player.send are not allowed on a client.
  • A message is one JSON text:
    • unreliable: at most 1023 bytes of UTF-8;
    • reliable: up to 64 KB;
    • a bigger message is dropped silently — no error, no event.

Input and the tick

TypeScript
const Move = Net.input({ x: 0, z: 0, jump: false })   // ONE per game: the defaults are the layout
Net.setInput(Move, { x, z })        // once per tick, from updateFixed, on a machine with a local player
rep.input(Move)                     // the owner's input for the tick being simulated
rep.hasInput                        // false until the owner's first input (input() gives the defaults till then)
Net.tick: number                    // fixed steps since the session started; 0 offline
Net.viewTick: number                // client: the server tick remote entities are shown at
  • Fields: a number becomes f32, a boolean one bit, and a number[] (1–64 long) that many f32s. The length is fixed. Strings, objects and nesting throw.
  • Numbers travel as f32: integers are exact up to 16 777 216.
  • Net.setInput replaces the whole input. A field you leave out takes its default, not its previous value.
  • Run the simulation in updateFixed(dt). It fires once per physics step, with dt exactly 1/60 s on every machine (aspects, Time).
    • The server gives each player's entity the input that client set for the same tick. A late input repeats the previous one (Net.stats.missed).
    • rep.input() hides where it runs: on the server it reads the owner's input; on the owner, host or offline it reads the local input.
  • Net.tick counts fixed steps (60 per second) while a session is up. A client runs 2–8 ticks ahead of the server, so its input arrives in time.
  • Net.viewTick on a client is fractional and about 100 ms behind the newest snapshot. It is the tick to name when telling the server "as I saw it". Elsewhere it equals Net.tick.

Replicated entities

TypeScript
const Kind = Net.kind(name, defaults, {
  transform?: "pos" | "pos+rot" | "none",       // default "none"
  client?: (rep: NetEntity, scene: Scene | null) => Node | void,
  discrete?: string[],                           // numeric fields never interpolated
  correction?: Partial<NetCorrection>,           // see Prediction
})
node.aspect(Replicated, { kind, owner?: NetPlayer })   // server: spawn it on every client
node.destroy()                                          // server: despawn it everywhere
Net.entities: readonly NetEntity[]
Net.entity(netId): NetEntity | undefined

The defaults object is the snapshot layout, with the same field rules as Net.input. The server writes state, and the whole state goes out in every snapshot, 20 per second.

TypeScript
node.replicated / node.get(Replicated)     // the aspect, same members on both sides:
rep.state                  // server: write it · client, remote: interpolated · client, own: newest authoritative
rep.netId / rep.isMine / rep.simulated / rep.ownerPlayer / rep.correction / rep.input(inp) / rep.hasInput
rep.entity: NetEntity      // the record: netId, kind, ownerId (0 = the server's own), owner, state, node
rep.entity.on("state", (state) => …) / .off("state", cb)   // client: state was refreshed
  • transform says which fields mirror the node. With "pos", state.pos ([x, y, z]) is read from node.position on the server and written to the proxy's node on clients. "pos+rot" adds state.rot, a quaternion [x, y, z, w]. Declaring either without that field throws.
  • client(rep, scene) runs on a client when the server spawns an entity of this kind.
    • Return the node, and add it to the scene yourself: scene is the open Scene or null.
    • The SDK attaches Replicated to the node you return and destroys it on despawn.
    • The factory may be assigned later (Kind.client = …), from the client's own module.
    • Without a factory, the entity exists in Net.entities with no node.
  • Ownership: owner is the player whose input drives the entity. simulated is true on the server and on the owner's machine. When a player leaves, the server destroys every entity they owned. An entity without an owner (ownerId 0) belongs to the server.
  • A player who joins receives every live entity. When a client's session ends, it destroys all the proxies it made. The server's own nodes stay.
  • Remote entities are interpolated. Numbers blend between two snapshots; booleans and discrete fields take the newer snapshot's value whole (use discrete for an index or a count). A client's state event fires with every snapshot for its own entities, and every frame for interpolated ones.
  • Only the server spawns. On a client, attaching Replicated yourself only logs a warning. On a proxy the aspect's option fields kind / owner are unset, so use rep.entity.kind and rep.ownerPlayer.

Prediction and correction

The owner's machine runs the same updateFixed with the same input as the server, so its own entity moves with no round trip. Each snapshot is compared with the prediction recorded for that tick. When they disagree, the node is shifted by the difference at the next fixed step (no rewind). rep.correction (a Vec3) then holds the jump as a visual offset that decays to zero. Draw with the offset, and never feed it to physics:

TypeScript
// the node is the simulated body; the visible mesh is a child that carries the correction
class Smooth extends Aspect<"smooth", Node> {
  body!: Node
  update() {
    const c = this.node.get(Replicated)?.correction
    if (c) this.body.position = [c.x, c.y, c.z]
  }
}

NetCorrection holds the kind's tuning, in world units and seconds:

  • deadbandY (default 0): a vertical disagreement below this is ignored. Use it for a body that snaps to the floor slightly differently on two machines.
  • snap (default Infinity): a disagreement of this size or more teleports, and correction stays zero.
  • tau (default 0.1 s): how fast the offset decays; 0 means no smoothing.

A disagreement under 0.005 units is ignored. Correction works on position only, so a kind needs transform: "pos" or "pos+rot" for it.

Diagnostics and limits

TypeScript
Net.stats      // live counters: snapshots, ahead, lead, nudges, corrections / maxCorrection / sumCorrection, missed, bytesOut
Net.simulate({ latency?: ms, jitter?: ms, loss?: percent })   // dev only: degrade what THIS side sends

Net.simulate affects only this machine's outgoing traffic, so call it on both ends for a round trip.

tick 60 per second (Time.fixedDt)
snapshots 20 per second, at most 1000 bytes each; an entity costs 5 bytes + 4 per number + its booleans packed 8 per byte. Entities that don't fit rotate through successive snapshots (one warning).
remote view about 100 ms behind the newest snapshot
kinds at most 255; array fields at most 64 numbers
messages unreliable ≤ 1023 bytes of JSON, reliable ≤ 64 KB; bigger ones are dropped silently
players maxClients 1–64
timeout about 10 s of silence ends a connection

Net.connect makes an unauthenticated LAN / dev connection. Anyone who can reach the port can connect, so accept is the only gate.

Where it runs

Net.supported is true where the transport is compiled in:

  • the desktop hosts: the window (lecodes desktop run) and the windowless lecodes serve / render --sim;
  • the full variant of the Android and iOS SDKs.

It is false on the 3d, 2d and core variants, the web player, Web Lite and the headless Bun host (lecodes render). Without a transport, Net.listen returns false and Net.connect rejects (both report "multiplayer is not supported on this host"). Offline play still works: messages, Replicated, input.

Note

a native app shell picks the smallest SDK variant that covers what its bundle uses; a bundle that names Net is put on full, the one variant with the transport. A forced "variant": "3d" / "2d" / "core" under ios / android in app.json loses it, and lecodes app sync warns.

Pitfalls

TypeScript
Net.send(Chat, data)                    // ✗ on a dedicated server: throws
Net.broadcast(Chat, data)               // ✓ the server addresses players

node.x += dx                            // ✗ on a remote proxy: the next interpolated pose overwrites it
if (rep.simulated) node.x += dx         // ✓ move only what this machine simulates

rep.state.hp = 0                        // ✗ on a client: the SDK overwrites it with the server's state
Net.send(Hit, { target: rep.netId })    // ✓ ask the server; it changes the state

Net.connect({ address: "localhost:40000" })   // ✗ "bad address"
Net.connect({ address: "127.0.0.1:40000" })   // ✓

See also

  • Aspects — updateFixed, where the shared simulation lives
  • Systems — scene-wide input and game rules
  • Physics — CharacterController for players
  • Network — WebSocket and fetch to ordinary web servers
  • Server functions — calls to the app's own backend