Multiplayer
Usage guide of the net.* kits: choosing the sync kit (state, claim, authoritative, rollback, async), turning it on, the settings and their defaults, server-side validation hooks, modding limits, events, the gfnet-server program and its flags, what each engine and libgfkits provide, errors.
Multiplayer for your game (net.* kits, gfnet-server) #
Pick how your game stays in sync across players, switch it on from the kits, tune it with a handful of settings, and run the server. Readable offline: gripforge docs net, or the MCP tool gripforge_docs (topic net).
What it is #
Every net.* kit runs the same Rust network core (gfnet-core): WebAssembly in the browser and in the Node server, a native library in Godot, Unity, Unreal and in the native server. One implementation everywhere, so a browser, a Godot build and a Unity build can play the same match. A game has one transport kit and one sync kit:
| Kit | Role |
|---|---|
net.transport | the session layer all sync kits run on (protocol gfnet/1): WebSocket, WebRTC (web), direct IP (native), handshake, heartbeats, resume after a drop, reconnection, per-peer rate limits |
net.state | server-validated actions on a shared, versioned state; no simulation tick |
net.claim | each client simulates the objects it has claimed; the server only arbitrates and relays |
net.authoritative | the server simulates; clients predict and reconcile; lag-compensated hits |
net.rollback | peers exchange only inputs and run the same deterministic simulation; rollback or lockstep |
net.async | nobody online at the same time: record a run, replay it as a ghost, boards, daily challenge, replay verification |
net.physics | one rigid-body world that is bit-identical on every peer and on the server (used when a sync kit's physics is shared) |
net.lobby | a private waiting room: nickname, roster, ready flags, host migration, server-validated start (web) |
net.rooms | an always-on rooms service: presence, challenges, picks and ready flags, one match server per match |
Choose the sync kit #
| Your game | Use | Why |
|---|---|---|
| Cards, board, word, trivia, strategy, party games; anything with hidden information; inventories and lobbies | net.state | every move is checked by rules on the server; each seat only receives what it may see |
| Co-op, sandbox, physics games where responsiveness beats cheat-proofing | net.claim | your own avatar, the crate you push, the car you drive react with no latency; not for competitive play |
| Competitive shooters, anything with an economy or a ranking | net.authoritative | the client can only send inputs; costs server CPU and needs a dedicated server |
| Fighting games, arena brawlers, small-roster action | net.rollback | tiny bandwidth, no input lag; needs a deterministic simulation; not for large player counts or late joiners |
| Time trials, ghosts, daily challenges, boards | net.async | plays against recorded runs; combine with a live kit for real-time matches |
Install one sync kit per project (the other net.* kits come in as needed).
The model of a web game: what the room reads #
For a game that runs in the browser on the kits, the network model is not a Godot choice: it is the sync kit you install. The web room service reads it from the project itself and starts gfnet-server with that model, so nothing is copied and nothing can drift:
| Installed sync kit | Room config.net.model | config.net.sync | config.net.physics |
|---|---|---|---|
net.claim | claim | the kit's settings that differ from the defaults (publishHz, leaseMs…) | net.physics settings when that kit is installed |
net.authoritative | authoritative | tickHz, snapshotHz, interpolationMs… | same |
net.rollback | rollback | mode, players, inputDelay… | same |
net.state | state | preset, actions, seats… | none |
A project with no sync kit has no room (not_networked). A room created by hand for a game that is not a project (the net field of POST /api/v1/network/rooms, { model, sync, physics }) takes the same shape, validated by the core. For a car or kart game that mostly steers its own vehicle, claim is the cheap, responsive choice; authoritative when hits or an economy must be cheat-proof; rollback for a small deterministic duel. The MCP tool gripforge_net_kit only covers the Godot listen-server snippets (GfNet); for a web game use gripforge_gamekit_install of the sync kit and read gripforge docs web-multiplayer.
Turn it on #
gripforge_gamekit_install { project_id: "gkp_…", id: "net.claim" } # brings net.transport with it
gripforge_gamekit_configure { project_id: "gkp_…", id: "net.transport",
config: { "app": "my-game@1", "url": "wss://play.example.com/gfnet" } }
gripforge_gamekit_configure { project_id: "gkp_…", id: "net.claim", config: { "publishHz": 30 } }
You write only the keys that differ from the defaults. gripforge kit api net.transport (or net.claim…) lists every setting, with its type, bounds and default, and says which ones can change on a running session.
Settings that matter #
net.transport (all keys: gripforge kit api net.transport):
| Key | Default | Meaning |
|---|---|---|
transport | auto | auto, websocket, webrtc, tcp_udp, enet. auto is WebSocket (WebRTC when a room is set) on the web, TCP+UDP then the WebSocket bridge on native (enet is treated as tcp_udp) |
app | gripforge-game@1 | application id and build: a client of another build is refused. Bump it whenever two builds can no longer play together |
url | "" | WebSocket address of the server; empty = same host as the page, path /gfnet |
host, port, udpPort | 127.0.0.1, 27015, 0 | native clients: server address; udpPort 0 = announced by the server |
role | client | web only: host runs the server in the page (listen server, WebRTC) |
signalUrl, room, iceServers | "", "", a public STUN server | WebRTC; a production game needs its own TURN server |
maxPeers | 16 | server capacity (a lower value refuses new peers, never kicks) |
heartbeatMs, timeoutMs, resumeMs | 1000, 5000, 10000 | liveness; a dropped peer is suspended for resumeMs and resumes with the same id |
rate.messagesPerSecond, rate.burst | 120, 240 | per-peer limits; over budget counts as a violation, and 20 violations kick the peer |
reconnect.* | on, 500 to 8000 ms, 10 tries | client backoff (0 tries = unlimited) |
Per sync kit, the settings you reach for first:
| Kit | Key | Default | Meaning |
|---|---|---|---|
net.claim | publishHz, snapshotHz | 20, 20 | rate of the owner's states and of the server's snapshots |
leaseMs, stickyMs | 750, 250 | an owner silent for leaseMs loses the object; a fresh owner cannot be stolen from for stickyMs | |
stealPolicy | priority | never, priority (strictly higher priority wins), always | |
onOwnerLeave | revert | objects created by a leaving peer: revert keeps them, owned by the server; destroy removes them | |
antiCheat, maxSpeed, worldLimit | on, 300, 2000 | plausibility checks on the states an owner publishes | |
physics | none | none, native (the engine's physics), shared (net.physics) | |
net.authoritative | tickHz, snapshotHz | 30, 20 | server tick and snapshot rates |
cmdRedundancy | 3 | each packet repeats the last commands | |
interpolationMs | 100 | remote entities are shown this far in the past | |
maxUnlagMs | 1000 | how far back the server rewinds others to judge a shot | |
relevanceRadius | 0 | metres of interest per player; 0 = everyone | |
predict | auto | none (everything interpolated), movement (the local player, reconciled on each snapshot), bodies (also the bodies around it; needs physics: native) | |
net.rollback | mode | rollback | rollback (predict and re-simulate) or lockstep (wait for every input) |
players, frameHz | 2, 60 | seats and simulation rate | |
inputDelay, maxRollback | 2, 8 | frames of delay, and how far back a re-simulation may go | |
hashEvery | 60 | frames between state hashes, to detect a desync | |
game | arena | the deterministic simulation; physics none or shared only | |
net.state | preset | none | board-game, card-game, party-lobby, rooms, simultaneous-duel |
actions, initial, visibility | your rules, starting state and what each seat may see | ||
turns.timeoutMs, seats.max | 0, 8 | turn timer and number of seats | |
maxStateBytes, actionsPerSecond | 262144, 10 | limits | |
net.async | record | both | inputs, samples or both |
frameHz, sampleHz, maxFrames | 60, 10, 36000 | recording rates and length | |
board, order | "", desc | the board and how it sorts | |
rules.* | verification: maxSpeed, maxScore, requireReplay, bounds… | ||
net.physics | gravity, hz, iterations | [0,-9.81,0], 0, 4 | the shared world |
net.state rules are data: argument schemas, conditions on the state, the seat, the turn and the phase, and effects (set, inc, push, remove, move, draw, deal, shuffle, random). The set is complete enough that most card and board games need no server code.
Server-side code: validation hooks #
Two places let your own code decide on the server:
net.stateactions declared with"hook": trueare checked against their schema, turn, phase and conditions, then handed to the server host, which accepts them (with an optional state patch) or rejects them with a reason. No answer withinhookTimeoutMs(5000) rejects the action. Engines expose it asGfNetState.validate(Godot),Validate/Validator(Unity) andOnValidate(Unreal).net.roomshooks: the rooms actions your host validates before the rules run (challenge,accept,decline,cancel,pick,ready,leave_match,watch,unwatch,join_server), refused with your reason. Used with your own login (auth: true). Seegripforge docs rooms-server.
Modding #
All net.* kits are services of game.engine: they declare no engine hook and no setting is open to mods. Transports, addresses, ports, capacities, limits and rates belong to the project and must be identical on every machine of a match. A mod that sets one is refused (config_not_moddable). With mods applied, the application id peers send is app followed by ~mods.<fingerprint>, so a client whose mods differ from the server's is refused with net.refused { code: "mods" }. Godot, Unity and Unreal have no mods: their handshake is unchanged.
Events and variables #
net.transport emits net.connected { id, resumed, transport }, net.disconnected { reason, final }, net.peer { id, on }, net.refused { code, msg } and net.overflow; variables net.status (idle, connecting, connected, reconnecting, closed), net.rtt and net.peers. The sync kits add their own (net.owner and net.denied for claim, lobby.changed for the lobby, rooms.changed, rooms.challenged, rooms.match_ready, rooms.match_ended for rooms…).
The server: gfnet-server #
gfnet-server is the dedicated server of the net.* kits, a native program. One process is one match or one waiting room. It runs the same core as the clients and only opens sockets, pumps the core and prints the lines a supervisor reads. Build it from the repository (cargo build --release -p gfnet-server in native/gfnet).
gfnet-server --model claim --app my-game@1 --port 27015
gfnet-server --model state --app my-game@1 --config match.json --exit-on-end
| Flag | Meaning |
|---|---|
--model | required: claim, authoritative, rollback, state, async or relay |
--app <id> | application id and build; clients of another build are refused |
--config <file.json> | { transport, sync, physics, server: { pumpMs }, lobby, match }; validated by the core: a bad key or value exits with code 2 and names its path |
--check-config | validate and exit (0 = valid), printing GF_NET_CONFIG_OK {...} |
--port, --udp-port, --host | listening ports and address (env GF_GAME_PORT) |
--max-peers, --pump-ms, --stats <seconds> | capacity (env GF_MAX_PLAYERS), pump interval (5 ms), periodic stats |
--lobby [json] | the net.lobby rules for the state model: ready, start and return are validated in the server |
--exit-on-end | when the match ends, print GF_NET_MATCH_END {reason, seat, result} and stop (exit 0) |
--stdio | the host owns the sockets (a front that terminates wss and checks tickets): one JSON object per line on stdin and stdout; no port is opened |
It prints GF_NET_READY {...} once it listens, closes cleanly on SIGINT / SIGTERM, and exits 70 if the core panics. One port serves WebSocket and native TCP+UDP clients.
Not served by the native server: WebRTC, TLS (terminate it at a proxy), and the rooms launcher. The Node server gf-net-server (package @gripforgeai/net, same flags) covers WebRTC signalling and the rooms service (startRoomsService); the rooms server starter (gripforge create rooms-server) is built on it. Godot, Unity and Unreal projects can also run headless as dedicated servers.
Engines #
| Engine | Client transport | Server |
|---|---|---|
| Web | WebSocket, or WebRTC with a room and signalUrl; the page can host a listen server (role: host) | gfnet-server, gf-net-server, or the page |
| Godot | WebSocket, TCP+UDP; wasm core in web exports | the same project headless, or gfnet-server |
| Unity | TCP+UDP, WebSocket on desktop | the same project in batch mode, or gfnet-server |
| Unreal | TCP+UDP, WebSocket | the same project as a server, or gfnet-server |
Native player and libgfkits | the core's own sockets: TCP+UDP, then the WebSocket bridge when fallback is on | role: host listens on the TCP / WebSocket port of the transport config |
libgfkits provides the net.core service (and net.http for kits that talk to a web service) to the games it runs, with sockets of the machine. What a page alone can open is refused by name, never silently replaced: WebRTC ("webrtc needs a host link") and wss:// addresses. For those, use the WebSocket bridge of a server or run the client in a page. The local player of a room you host joins its own server through the listening port, as a TCP+UDP client of 127.0.0.1.
From a web page without the kits #
@gripforgeai/net is the package for a plain page or a three.js / React Three Fiber scene: typed sessions with the same options and event names as the engine wrappers.
import { connect } from '@gripforgeai/net';
const net = await connect({ model: 'claim', url: 'wss://play.example.com/gfnet', app: 'my-game@1' });
net.on('peer', ({ id, on }) => console.log(id, on ? 'joined' : 'left'));
Options are validated before the core is created: an unknown key, a wrong type or a value out of bounds throws a NetConfigError whose path names it. Commands never throw when the core refuses one: create returns 0, claim, input and the others return false, and command() returns { ok: false, error }.
When it does not work #
| Symptom | Cause and fix |
|---|---|
net.refused { code: "app" } | the client and the server have a different app; bump and deploy both together |
net.refused { code: "mods" } | the server runs other mods; match the mods, or set the dedicated server's transport.app to a client's app() |
net.refused { code: "full" }, rate, timeout | retried with backoff; raise maxPeers or the limits if legitimate |
version, app, model, started, invalid, kicked, replaced | fatal: no reconnection |
| gfnet-server exits 2 | the config is invalid; the message names the path (config.transport.heartbeatMs); check with --check-config |
| WebRTC refused on a native build | not served natively; use WebSocket or TCP+UDP |
| Rollback desync reported | the simulation is not deterministic (unordered iteration, floats, unseeded random); use physics: shared for physics |