Skip to content

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 Net

Net.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 lobby

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")

Send bytes to a connection.

Returns: Bool — true if the message was accepted for sending.

Parameters:

  • conn (Num) — a connection id from connect, or a peer id from a poll event.
  • 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) — true for reliable + ordered delivery, false for unreliable.
Net.send(conn, "hello", true) // reliable
Net.send(conn, snapshotBytes, false) // unreliable (e.g. a state snapshot)

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")
}
}

Close a single connection (graceful).

Parameters:

  • conn (Num) — the connection id to close.

Returns: Bool — true if this transport is currently listening (server role).

Returns: Num — the number of currently-open peer connections.

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.

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.

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 malformed
if (shape == null) return // ignore the message; do NOT trust it

It is strict on purpose:

InputResultWhy
"42", "-7.25", " 13 "the numbersurrounding whitespace is tolerated
"1e400"nullout of range — this is the one that killed servers
"nan", "inf"nulla caller asking for a number wants neither
"12abc", "1.2.3"nulltrailing garbage is a failure, not a silent 12
"", "abc", a non-stringnullnot a number

parseNum needs no connection — it is pure text handling, so you can validate input before you ever call startServer or connect.

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.

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.

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.

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"])
}

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.

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.

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.

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).

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.

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 = 7777
tick_rate = 60
# All default to 0 = a perfect link, so omitting them changes nothing.
sim_packet_loss_send = 40.0 # percent of outbound packets discarded
sim_packet_loss_recv = 0.0 # percent of inbound packets discarded
sim_lag_send_ms = 80 # extra delay on every outbound packet
sim_lag_recv_ms = 0 # extra delay on every inbound packet
sim_packet_reorder_send = 10.0 # percent of outbound packets given extra delay
sim_reorder_time_ms = 20 # how much extra delay a reordered packet gets

These 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).

  • 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 via plume3d --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 by owner == 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_probe is the headless test client — it steers via typed input and finds its entity by owner attribution.

  • apps/net_conditions_demo — shows the sim_* keys above in action. Run it with plume3d --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 its game.toml roughly half get through; set every sim_* key to 0 and 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].