Net
Net is a low-level networking transport for authoritative dedicated-server
multiplayer, backed by Valve’s GameNetworkingSockets
(reliable-ordered and unreliable messages over UDP, encrypted). A game acts as a
server (startServer) or a client (connect); send/poll move raw bytes.
Net is a static facade (like Audio) over the process’s single transport. It is
available on desktop builds; on a build without networking, every method safely returns a
falsey value (false / 0 / an empty list) so game code degrades gracefully.
In the --server run mode the engine already listens on
[Network].port, so a dedicated server does not call startServer — it just polls events
and responds. startServer is for hosting a server from inside a windowed client (a
listen-server host).
All methods are static. Import the class from the engine module:
import "engine" for NetNet.startServer(port) / Net.startServer(port, maxClients)
Section titled “Net.startServer(port) / Net.startServer(port, maxClients)”Start listening for client connections on a UDP port (server / listen-host role). In
--server mode the engine already does this from [Network].port, so you only call this to
host from a windowed client.
The one-argument form is capped at [Network] max_clients (32 when unconfigured) —
inbound connections past the cap are refused, a DoS guard the previously-uncapped listen
path lacked. The two-argument form sets the cap explicitly; 0 means deliberately
unlimited.
Returns: Bool — true if the listen socket was created.
Parameters:
port(Num) — UDP port to listen on (1–65535).maxClients(Num, optional) — hard connection cap;0= unlimited.
if (!Net.startServer(7777)) System.print("could not host on :7777")Net.startServer(7777, 8) // a small co-op lobbyNet.connect(host, port)
Section titled “Net.connect(host, port)”Connect to a server (client role).
Returns: Num — a connection id to use with send/close, or 0 on failure.
Parameters:
host(String) — an IP literal (e.g."127.0.0.1") or"localhost". GameNetworkingSockets does not resolve DNS names; a PlayFlow endpoint is already an IP.port(Num) — the server’s UDP port.
var conn = Net.connect("127.0.0.1", 7777)if (conn == 0) System.print("connect failed")Net.send(conn, bytes, reliable)
Section titled “Net.send(conn, bytes, reliable)”Send bytes to a connection.
Returns: Bool — true if the message was accepted for sending.
Parameters:
conn(Num) — a connection id fromconnect, or a peer id from apollevent.bytes(String) — the payload, treated as a raw byte buffer (embedded NUL bytes are preserved — it is not a C string). Max 512 KiB per message.reliable(Bool) —truefor reliable + ordered delivery,falsefor unreliable.
Net.send(conn, "hello", true) // reliableNet.send(conn, snapshotBytes, false) // unreliable (e.g. a state snapshot)Net.poll()
Section titled “Net.poll()”Drain the events that have arrived since the last call. Call this once per tick. Each
event is a Map.
Returns: List of event Maps. Each map has:
"type"(String) —"connect","data", or"disconnect"."conn"(Num) — the peer connection id."payload"(String) — present only on"data"events: the received bytes.
for (ev in Net.poll()) { if (ev["type"] == "connect") { System.print("peer %(ev["conn"]) joined") } else if (ev["type"] == "data") { Net.send(ev["conn"], ev["payload"], true) // echo } else if (ev["type"] == "disconnect") { System.print("peer %(ev["conn"]) left") }}Net.close(conn)
Section titled “Net.close(conn)”Close a single connection (graceful).
Parameters:
conn(Num) — the connection id to close.
Net.isServer()
Section titled “Net.isServer()”Returns: Bool — true if this transport is currently listening (server role).
Net.connectionCount()
Section titled “Net.connectionCount()”Returns: Num — the number of currently-open peer connections.
Net.rtt(conn)
Section titled “Net.rtt(conn)”Returns: Num — round-trip time to the connection in milliseconds (the transport’s live
estimate), or null when not yet measured or the connection is invalid. Show it in a ping
display, or use it to pick an interpolation delay.
Net.serverTick() / Net.serverTime()
Section titled “Net.serverTick() / Net.serverTime()”Returns: Num or null — the newest replication snapshot’s server clock, carried by
the wire envelope: serverTick() is the server’s fixed-simulation-step index, serverTime()
the matching time in seconds. null until the first snapshot lands. This is the coordinate
system later prediction phases are written in — a client can already say which server tick
the state it is rendering came from.
Net.parseNum(text)
Section titled “Net.parseNum(text)”Parse untrusted text as a finite number. Returns the number, or null if text is not one.
var parts = ev["payload"].split(":")var shape = Net.parseNum(parts[1]) // null on anything malformedif (shape == null) return // ignore the message; do NOT trust itIt is strict on purpose:
| Input | Result | Why |
|---|---|---|
"42", "-7.25", " 13 " | the number | surrounding whitespace is tolerated |
"1e400" | null | out of range — this is the one that killed servers |
"nan", "inf" | null | a caller asking for a number wants neither |
"12abc", "1.2.3" | null | trailing garbage is a failure, not a silent 12 |
"", "abc", a non-string | null | not a number |
parseNum needs no connection — it is pure text handling, so you can validate input before you ever
call startServer or connect.
Replication (server-authoritative)
Section titled “Replication (server-authoritative)”The engine can sync entity transforms (position + rotation) for you. The server
declares each networked entity’s transform every tick and the engine broadcasts a
snapshot to all clients automatically (after Game.update); the client reads the
latest authoritative state back. Replication rides the same connections as game messages
on a separate channel, so it never appears in Net.poll().
Net.setReplicatedEntity(id, x, y, z, qx, qy, qz, qw)
Section titled “Net.setReplicatedEntity(id, x, y, z, qx, qy, qz, qw)”Server. Upsert a replicated entity by id with a position (x,y,z) and rotation
quaternion (qx,qy,qz,qw). Call each tick for every networked entity. The server is the
sole transform writer — clients express intent through typed input
and read state back; they never send positions.
Net.setEntityOwner(id, conn)
Section titled “Net.setEntityOwner(id, conn)”Server. Tag entity id as OWNED by the client on connection conn: the entity’s
"owner" field becomes that connection’s server-assigned client id, so the owning peer can
recognize its own entity (owner == Net.clientId()). conn = 0 clears ownership.
Order-independent with setReplicatedEntity. Ownership is session-scoped — a
reconnecting player gets a new client id; re-tag on respawn (see the arena server).
Net.removeReplicatedEntity(id) · Net.clearReplicatedEntities()
Section titled “Net.removeReplicatedEntity(id) · Net.clearReplicatedEntities()”Server. Stop replicating one entity (clients drop it on the next snapshot), or clear the whole set.
Net.replicatedEntities()
Section titled “Net.replicatedEntities()”Client. Returns: List of Maps
{ "id", "owner", "x", "y", "z", "qx", "qy", "qz", "qw" } — the latest authoritative
snapshot. "owner" is the owning connection’s client id (0 = server/unowned); compare it
against Net.clientId() to find your own entity.
Net.replicatedEntitiesLerp(alpha)
Section titled “Net.replicatedEntitiesLerp(alpha)”Client. The same list, interpolated between the previous and latest snapshot by
alpha in [0, 1] (0 = previous, 1 = latest) — for smooth client-side motion. Drive
alpha from time-since-last-snapshot / the snapshot interval.
// Server (in --server mode; engine broadcasts after update):Net.setReplicatedEntity(playerId, px, py, pz, 0, 0, 0, 1)
// Client:for (e in Net.replicatedEntities()) { var node = sceneNodeFor(e["id"]) node.setPosition(e["x"], e["y"], e["z"])}Server authority — typed input
Section titled “Server authority — typed input”A client expresses intent, never position (PLM-334 / ADR 0124): a typed, tick-stamped
input frame — move vector, aim, buttons — decoded in C++ at the wire boundary with the same
malformed-input treatment as every untrusted path. The movement hot path contains no
string parsing end to end; keep game strings (Net.send/Net.poll) for lifecycle
(join/leave/chat), as the arena demo does.
Net.sendInput(conn, moveX, moveZ, yaw, pitch, buttons)
Section titled “Net.sendInput(conn, moveX, moveZ, yaw, pitch, buttons)”Client. Returns: Bool. Send this tick’s intent to the server on conn. moveX/
moveZ are clamped to [-1, 1] (intent, not velocity — the server owns speed and bounds);
yaw/pitch are absolute radians, quantized ONCE to the wire’s u16 buckets (~0.006°/
0.003° — below perception); buttons is a game-defined bitmask. The engine stamps the
local fixed-step tick (Physics.tick). Unreliable + latest-wins: a lost frame costs one
tick of staleness, never a stall. Call it every tick while playing.
Net.inputFor(conn)
Section titled “Net.inputFor(conn)”Server. Returns: Map { "tick", "moveX", "moveZ", "yaw", "pitch", "buttons" } —
the NEWEST input from that connection (attributed by connection identity — unforgeable),
or null if none arrived yet. Aim comes back dequantized to the wire’s exact bucket
values. A reordered late frame never replaces newer intent. Read it each tick and
integrate: x = x + input["moveX"] * speed * dt.
Net.clientId()
Section titled “Net.clientId()”Client. Returns: Num — OUR server-assigned client id (from the handshake), or
null before it completes. A per-connection session id, not an account. Compare
against entity "owner" fields to find your own entity.
Net.serverAckTick()
Section titled “Net.serverAckTick()”Client. Returns: Num — the newest input tick the server has consumed from us
(carried by every snapshot’s envelope), or null before the first. This is the anchor a
future prediction layer replays from; today it is a liveness signal (“my input reaches the
server”).
// Client, every tick while playing:Net.sendInput(_server, moveX, moveZ, heading, 0, buttons)
// Server, every tick:var input = Net.inputFor(conn)if (input != null) { e["x"] = clamp(e["x"] + input["moveX"] * SPEED * dt) e["z"] = clamp(e["z"] + input["moveZ"] * SPEED * dt)}Net.setReplicatedEntity(conn, e["x"], y, e["z"], 0, h.sin, 0, h.cos)Protocol notes (wire envelope + handshake)
Section titled “Protocol notes (wire envelope + handshake)”Connections complete an engine-internal, version-gated handshake before they surface: the
connect event you see from Net.poll() fires only once both peers agree on the protocol
version — “connected” still means “ready to talk to”. A peer on an incompatible engine build
never surfaces at all (its connection is closed during the handshake). Replication snapshots
carry a server tick and are ordered by it — a late, reordered packet can never regress
entity state — and every connection has an engine-enforced receive budget. None of this needs
game code; it is described here because a proxy or packet log will show the 0x02
handshake and 0x03 input channels and the 18-byte snapshot envelope (protocol v2:
per-entity ownership, per-connection input acks — an older engine build is refused at the
handshake rather than mis-decoded).
Threading & cadence
Section titled “Threading & cadence”GameNetworkingSockets runs its own internal service thread, but all Net.* calls happen on
the main thread, driven by the engine each frame/tick. The engine services the transport
(runs callbacks, receives messages) once per frame; your Net.poll() consumes what was
received. Poll every tick so connection events (accepts, disconnects) are handled promptly.
Simulating an adverse link
Section titled “Simulating an adverse link”Replication, interpolation and prediction all look perfect on loopback and LAN, where
delivery is instant and lossless. That is exactly where netcode bugs hide. game.toml can tell
the engine to degrade its own link so you can see what a real player experiences:
[Network]port = 7777tick_rate = 60
# All default to 0 = a perfect link, so omitting them changes nothing.sim_packet_loss_send = 40.0 # percent of outbound packets discardedsim_packet_loss_recv = 0.0 # percent of inbound packets discardedsim_lag_send_ms = 80 # extra delay on every outbound packetsim_lag_recv_ms = 0 # extra delay on every inbound packetsim_packet_reorder_send = 10.0 # percent of outbound packets given extra delaysim_reorder_time_ms = 20 # how much extra delay a reordered packet getsThese apply to both roles — set them on a client’s game.toml to test a laggy player,
not just a laggy server. They are applied at the UDP layer, so they are process-global.
Percentages accept an integer or a float (40 and 40.0 both work).
Worked examples
Section titled “Worked examples”-
apps/net_echo_server+apps/net_echo_client— a real client/server pair. Run the server (plume3d --server apps/net_echo_server) in one terminal and the windowed client (plume3d apps/net_echo_client) in another; the client connects, sends a message, and prints the server’s echo. -
apps/net_loopback_demo— a self-contained demo that connects to itself; run it viaplume3d --server apps/net_loopback_demo. (It plays both roles in one process — don’t pair it with a separate server.) -
apps/net_replication_demo— self-contained replication smoke test (plume3d --server apps/net_replication_demo). -
apps/net_arena_server+apps/net_arena_client— a visual multiplayer demo, and the reference for the full server-authority model. Run the server (plume3d --server apps/net_arena_server), then one or more windowed clients (plume3d apps/net_arena_client). Each client gets a selection screen — four labelled shapes you click (mouse-ray picked) or pick with keys 1-4 (cube / sphere / pyramid / octahedron) — then WASD steers your shape: the client only ever sends typed intent (Net.sendInput), the server integrates it (Net.inputFor) and replicates every transform back with ownership, and your own shape — found byowner == Net.clientId()— renders enlarged. Rotation is server-authoritative too (recovered from the replicated quat). Press ESC to return to selection and change shape (despawns + respawns you without disconnecting; lifecycle rides the reliable string channel, movement never does).apps/net_arena_probeis the headless test client — it steers via typed input and finds its entity by owner attribution. -
apps/net_conditions_demo— shows thesim_*keys above in action. Run it withplume3d --server apps/net_conditions_demo: it acts as server and its own client, broadcasts a moving entity, and reports how many snapshots actually arrived. With the loss configured in itsgame.tomlroughly half get through; set everysim_*key to0and re-run to watch it jump to ~99%. That contrast is the point.
For cloud hosting (matchmaking a client to a dedicated server), see the PlayFlow API.
See the Dedicated Server guide for the --server run mode and
Configuration for [Network].