# 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 ```text 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.state` actions declared with `"hook": true`** are 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 within `hookTimeoutMs` (5000) rejects the action. Engines expose it as `GfNetState.validate` (Godot), `Validate` / `Validator` (Unity) and `OnValidate` (Unreal). - **`net.rooms` `hooks`**: 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`). See `gripforge 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.`, 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`). ```sh 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 ` | application id and build; clients of another build are refused | | `--config ` | `{ 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 ` | 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. ```ts 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 |