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
// 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) // offlinelecodes 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 # offlineA 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.
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: booleanNet.launchreports 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:portgives{ role: "client", address, port: 40000, maxClients: 16 }.lecodes serveandlecodes desktop run --server | --connectpass these flags.
Net.listenoptions: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. Passdedicated: falseto make a--serverwindow a host.accept: see the handshake below.
- From offline,
Net.listenmakes this machine the host, and the offlineNet.mestays as the host's player. Net.connecttakes 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") andhello(below).Net.disconnect()on a client goes offline and firesdisconnect("disconnected"). On a host or server it drops every player (aleaveeach, reason"server closed") and goes offline.- Stopping the server's process (Ctrl+C on
lecodes serve) ends the session: every client getsdisconnectwith"disconnected by server".
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 NetWhen 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
// 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).
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
disconnectwith"disconnected by server"; - the server's
leaveevent and the other clients see reason"kicked"; - your
reasontext reaches no one.
Players
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.
rttandlosson 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.
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
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)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.sendis not allowed on a dedicated server, which addresses players instead.Net.broadcastandplayer.sendare 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
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
numberbecomes f32, abooleanone bit, and anumber[](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.setInputreplaces 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, withdtexactly 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.
- The server gives each player's entity the input that client set for the same tick. A late
input repeats the previous one (
Net.tickcounts 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.viewTickon 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 equalsNet.tick.
Replicated entities
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 | undefinedThe 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.
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 refreshedtransformsays which fields mirror the node. With"pos",state.pos([x, y, z]) is read fromnode.positionon the server and written to the proxy's node on clients."pos+rot"addsstate.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:
sceneis the openSceneornull. - The SDK attaches
Replicatedto 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.entitieswith no node.
- Return the node, and add it to the scene yourself:
- Ownership:
owneris the player whose input drives the entity.simulatedis 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 (ownerId0) 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
discretefields take the newer snapshot's value whole (usediscretefor an index or a count). A client'sstateevent fires with every snapshot for its own entities, and every frame for interpolated ones. - Only the server spawns. On a client, attaching
Replicatedyourself only logs a warning. On a proxy the aspect's option fieldskind/ownerare unset, so userep.entity.kindandrep.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:
// 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(defaultInfinity): a disagreement of this size or more teleports, andcorrectionstays 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
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 sendsNet.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 windowlesslecodes serve/render --sim; - the
fullvariant 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.
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
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 —
CharacterControllerfor players - Network —
WebSocketandfetchto ordinary web servers - Server functions — calls to the app's own backend