Rooms, codes, quick play and relays come from vbgnt's room service (the same
one Godot games use). The model is host authoritative: the host (peer 1)
runs the game; guests send their input and draw what the host sends back.
The host is a player (peer to peer, free) or, for games that opt in, this
same game running headless on a vbgnt cloud host. Everything polls: no
callbacks, call net_state() and drain net_poll() every frame.
API
net_available() -> bool online play possible in this build (true on vbgnt; false locally / in tests)
net_set_name(name) sent with joins; the host reads it with net_peer_name
net_host(max_players, public, mode) open a room you host (2..8); public lists it for quick play
net_join(code) join by room code ("K7PQ")
net_quick_play(mode, max_players) a seat in an open public room of this mode, or a new one
(on a cloud host when the game has one, else you host)
net_server_room(max_players, public, mode) a cloud-hosted room you join as a player (NET_WAITING while it boots)
net_warm_up(mode, max_players) the online lobby opened: start booting a cloud host early (fire and forget)
net_leave() host: closes the room for everyone
net_set_open(open) host: stop / resume accepting players (match started)
net_state() -> NET_IDLE | NET_CONNECTING | NET_WAITING | NET_HOSTING | NET_JOINING | NET_CONNECTED | NET_FAILED
net_error(out&) why it failed: room_not_found, room_full, room_closed, version_mismatch,
connect_timeout, signaling_timeout, server_timeout, not_configured, ...
net_room_code(out&) net_invite_link(out&) net_invite_code(out&) (?room=CODE on the play URL)
net_is_host() net_is_server() net_is_server_room() net_my_id() net_max_players()
net_peer_count() net_peer_id(i) net_peer_name(id, out&)
net_send(to, channel, bytes) -> bool to: a peer id, or NET_ALL (host: every guest; guest: the host)
channel: NET_RELIABLE (ordered, never lost) or NET_UNRELIABLE (newest wins)
net_poll() -> NET_NONE | NET_MESSAGE | NET_PEER_JOINED | NET_PEER_LEFT | NET_HOST_LEFT
net_msg_from() net_msg_channel() net_msg_len() net_msg_data(out&) the event net_poll returned
net_in_room() -> bool hosting, or connected to a host
net_server_shutdown(code) cloud host only: close the room and exit (match over)
Messages: build with net_put_u8 / u16 / i32 / f32 / float2 / float3 / quat /
str, read back in the same order with a NetReader (net_read_*, then
net_read_ok(data, r) to check nothing ran past the end). Rates: NetTimer +
net_every(timer, hz, dt). Smooth remote movement: one NetTrack per remote
entity, net_track_push(tr, pos, rot) when a snapshot arrives,
net_track_pos(tr) / net_track_rot(tr) when drawing (100 ms behind, always
between two real states). net_track_index(tracks, id) finds or adds a track.
The rules
- The host decides. Guests send input (what the player pressed), never
positions, hits or scores. The host simulates and sends results. Never trust
a guest's message: clamp every value you read (
clamp(mv.x, -1.0, 1.0)). - Unreliable for state, reliable for events. Input every frame (30 Hz is
plenty) and snapshots (15..20 Hz) go on
NET_UNRELIABLE: a lost one is replaced by the next. Joins, chat, pickups, deaths, turns, round results go onNET_RELIABLE. - Small messages. A snapshot is a type byte, a count, then per entity an id and what changed. Under 1 KB per snapshot; never send textures, models or long strings each frame.
- Peer ids are below 2^24 (exact as f32). The host is always 1. Guests only talk to the host: to reach other guests, the host forwards.
- Rooms end with the host (
NET_HOST_LEFTon guests): show a message and go back to the menu. Cloud-hosted rooms keep going until empty. - Joining late: when the host sees
NET_PEER_JOINED, send that peer a reliable "welcome" with the full current state (and its spawn). - Local builds and
gander testhave no room service (net_available()is false): keep single-player / same-screen play working without it, and show "Online play is available when published" instead of failing. - Show the room code big, with a Copy invite button (
net_invite_link), and a spinner with the code whileNET_WAITING(a cloud host takes up to a minute to boot the first time;net_warm_upwhen the lobby opens shortens it).
Cloud hosts
Opt in with gander.json:
{ "net": { "server": true } }
Then vbgnt can run the game itself as the room's host on demand (quick play
and net_server_room use it; up to 16 players). The SAME code runs there,
compiled natively and headless: net_is_server() is true, there is no local
player, no window and no sound, and main() should skip menus. Branch on it:
export "gander_frame" fn frame(dt: f32) {
if gander::net_is_server() {
// no menu, no local player: simulate the room, send snapshots
}
}
The cloud host exits by itself once the room has been empty for 45 s (90 s if
nobody ever came), or on net_server_shutdown(0).
Recipe: co-op arena (snapshots + input forwarding)
let MSG_INPUT = 1;
let MSG_SNAP = 2;
struct Player { id: i32, pos: float2, input: float2 }
var players: Player[>..] = [];
var tracks: gander::NetTrack[>..] = [];
var send_timer = gander::NetTimer { .. };
var snap_timer = gander::NetTimer { .. };
fn player_index(id: i32) -> i64 {
for i: i64 in 0..players.len { if players[i].id == id { return i; } }
players.push(Player { id: id, pos: float2 { 640.0, 360.0 }, input: float2 { 0.0, 0.0 } });
players.len - 1
}
fn host_frame(dt: f32) {
if !gander::net_is_server() { let me = player_index(1); players[me].input = gander::move_axis(); }
while true {
let ev = gander::net_poll();
if ev == gander::NET_NONE { break; }
if ev == gander::NET_PEER_JOINED { let i = player_index(gander::net_msg_from()); }
if ev == gander::NET_MESSAGE {
var data: u8[>..] = [];
gander::net_msg_data(data);
var r = gander::NetReader { .. };
if gander::net_read_u8(data, r) == MSG_INPUT {
let mv = gander::net_read_float2(data, r);
let i = player_index(gander::net_msg_from());
players[i].input = float2 { clamp(mv.x, -1.0, 1.0), clamp(mv.y, -1.0, 1.0) };
}
}
}
for &p in players { p.pos = p.pos + float2 { p.input.x, -p.input.y } * (260.0 * dt); }
if gander::net_every(snap_timer, 20.0, dt) {
var m: u8[>..] = [];
gander::net_put_u8(m, MSG_SNAP);
gander::net_put_u8(m, players.len as i32);
for p in players { gander::net_put_i32(m, p.id); gander::net_put_float2(m, p.pos); }
let ok = gander::net_send(gander::NET_ALL, gander::NET_UNRELIABLE, m);
}
for p in players { gander::circle(p.pos.x, p.pos.y, 26.0, gander::WHITE); }
}
fn guest_frame(dt: f32) {
while true {
let ev = gander::net_poll();
if ev == gander::NET_NONE { break; }
if ev == gander::NET_MESSAGE {
var data: u8[>..] = [];
gander::net_msg_data(data);
var r = gander::NetReader { .. };
if gander::net_read_u8(data, r) == MSG_SNAP {
let n = gander::net_read_u8(data, r);
for k: i32 in 0..n {
let id = gander::net_read_i32(data, r);
let pos = gander::net_read_float2(data, r);
let t = gander::net_track_index(tracks, id);
gander::net_track_push(tracks[t], float3 { pos.x, pos.y, 0.0 }, gander::quat_identity());
}
}
}
}
if gander::net_every(send_timer, 30.0, dt) {
var m: u8[>..] = [];
gander::net_put_u8(m, MSG_INPUT);
gander::net_put_float2(m, gander::move_axis());
let ok = gander::net_send(gander::NET_HOST, gander::NET_UNRELIABLE, m);
}
for tr in tracks { let p = gander::net_track_pos(tr); gander::circle(p.x, p.y, 26.0, gander::WHITE); }
}
export "gander_frame" fn frame(dt: f32) {
let st = gander::net_state();
if st == gander::NET_HOSTING { host_frame(dt); }
else if st == gander::NET_CONNECTED { guest_frame(dt); }
}
Start it from a menu: gander::net_quick_play("arena", 4) for "Play online",
gander::net_host(4, false, "") + show the code for "Invite friends", and on boot
net_invite_code(code) + net_join(code) when the page was opened from an
invite link.
Recipe: turn-based board game
Everything on NET_RELIABLE: the guest sends MOVE square; the host checks
it is that player's turn and the move is legal, applies it, then sends
STATE (the whole board, it is small) to NET_ALL. Lock the room with
net_set_open(false) when the game starts. No snapshots, no interpolation.
Recipe: party game (4 players, input only)
Guests send INPUT buttons every frame on NET_UNRELIABLE (a bit field of
action_downs) plus PRESS action on NET_RELIABLE for one-shot presses
that must not be lost (jump, buzz in). The host runs the round and sends
EVENT messages (round start, score, winner) on NET_RELIABLE and positions
on NET_UNRELIABLE.
Native builds
Exported desktop and mobile builds (Windows, macOS, Linux / Steam Deck,
Android, iOS) play online too, in the same rooms as the web build: a native
player can host a room web players join, or join a web player's room, with
the same code and nothing to change in the game. The export writes the room
service address and the build id next to the game (gander-config.json; the build
id is the hash of game.c, the same one the web build reports, so the room
service keeps native and web players of one build together). net_invite_code
reads --room CODE from the command line natively, the web build's ?room=.
Test it with gander test --native --realtime (frames paced in real time so
connections can happen).
Not yet
Voice chat (Godot games have it through vbgnt_net).