Home/GANDER docs/GANDER online multiplayer

GANDER online multiplayer

Host-authoritative online play: rooms, codes, quick play and cloud hosts, with recipes for action and turn-based games.

net.mdllms-full.txt

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 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:

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