# gander online multiplayer 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 ```text 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 on `NET_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_LEFT` on 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 test`** have 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 while `NET_WAITING` (a cloud host takes up to a minute to boot the first time; `net_warm_up` when the lobby opens shortens it). ## Cloud hosts Opt in with `gander.json`: ```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: ```goose 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) ```goose 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_down`s) 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).