Skip to the content.

Wire protocol

Protocol version 1. Implemented in the feverlink3_core crate, core/src (protocol.rs, crypto.rs, lobby.rs, session/); every rule here has a test in core/tests.

Frame

u32  length      big-endian; counts everything after this field; 12 ≤ length ≤ 1 048 576
u8   version     1
u8   kind        MessageKind
u16  sender      0 = host, 1..65534 = a player, 65535 = not yet assigned
u64  counter     per sender, starts at 1, strictly increasing
[..] body

A length outside the bounds closes the connection before anything is allocated.

Sealed frames (everything except the three handshake kinds): body = AES-256-GCM(key, nonce, payload) ‖ 16-byte tag, with the 12 header bytes as additional authenticated data and nonce = u32(sender) ‖ u64(counter). A receiver keeps a 64-frame replay window per sender and records a counter, and makes the sender’s window, only after the tag verified, so a forged frame can neither burn a counter nor grow the receiver. A sender id belongs to one connection for the whole session (the host never gives one twice, a player who reconnects is a new sender) and its counter never restarts, so a nonce is never used twice under a key; a secret is used for one session only. A payload that does not fit a frame (more than 1 048 576 - 12 - 16 bytes) is not sent.

Payloads are UTF-8 JSON, camelCase, nulls omitted, byte arrays base64, doubles in their shortest exact form (a parser must give back the same double: serde_json needs float_roundtrip for that). Unknown fields are ignored; a change of meaning needs a new kind.

Key

secret = 16 random bytes carried by the join code. ikm = secret, or secret ‖ PBKDF2-HMAC-SHA256(password, salt = secret, 600 000 iterations, 32 bytes) when the host set a lobby password. key = HKDF-SHA256(ikm, salt = "FeverLink3 v1", info = "session", 32 bytes).

Join code

Base32 (RFC 4648 alphabet, no padding; dashes, spaces and case ignored on input) of:

u8   version (1)
u32  created, unix seconds
16   secret
u8   endpoint count (0..8)
per endpoint: u8 family (4 | 6), address bytes, u16 port
4    first 4 bytes of SHA-256 of everything above

One IPv4 endpoint gives 53 characters. Anything that fails the check or the shape decodes to nothing, never to an error.

Handshake

host   → conn    Challenge  { nonce: 16 bytes, protocol: 1, version: "0.1" }      unsealed, sender 65535
conn   → host    Join       { name, version, proof, resume?, caps?, mods? }        unsealed, sender 65535
                 proof = HMAC-SHA256(key, nonce ‖ UTF8(name))
                 resume = { id, mac, last? }, from a player coming back (see Reconnect, Catching up)
host   → conn    Welcome    { you, hostName, version, players[], session, resume?, resumed?, modsChecked?, catchUp? }  sealed, sender 0
   or            Reject     { reason, detail?, mods? }  then the host closes the connection  unsealed

Reject reasons: bad-proof (wrong key or password), version (detail names both versions, or says the player’s FeverLink3 sends no mod list), mods (see “Mod list”), full (detail: 8 players, and how many of them are away and holding their slot), malformed, flood (too many frames, see Limits), timeout (the handshake did not finish in time).

After Welcome the player seals with its assigned id. The host checks that the sender id in every frame equals the id bound to that connection; a mismatch is dropped. A player accepts sealed frames from sender 0 only. An unsealed Reject is honoured only before the Welcome; afterwards the host ends a session with the sealed Kicked (see Leave and removal), because anyone on the path can write an unsealed frame.

A player answers a Challenge only if its nonce is 16 bytes and the protocol number is its own. It honours a Reject only while it is still joining (a Reject is unsealed: anyone on the path can write one), and a second Welcome on a connection is dropped (it would replace the player’s sealer and restart its counter).

Before a connection has joined, the host reads its first frame with a limit of 4096 bytes (FrameCodec::HANDSHAKE_MAX_LENGTH), closes it if no accepted Join arrived within 15 s of opening, and holds at most 32 such connections at once. The transport adds its own limits (64 connections, 16 per address, a 10 s write timeout, 1000 frames and 1 MiB per second per connection); see docs/SECURITY.md, section 6.

Mod list

Two games that load the same save but run other mods, another game build or another FeverLink3 build run apart from the first minute (core/src/session/mods.rs). Two checks, both before any world data is sent:

Who may send what

Kind Value Direction Payload
Challenge 1 host → connection {nonce, protocol, version}
Join 2 connection → host {name, version, proof, resume?: {id, mac, last?}, caps?, mods?}
Reject 3 host → connection {reason, detail?, mods?}
Welcome 10 host → player {you, hostName, version, players: [{id, name, ready}], session, resume?: {token, epoch}, resumed?, modsChecked?, catchUp?} (catchUp: see Catching up)
Session 11 host → all {started, speed, pauseAt?, lead, resync?, modWarnings?, stalled?, loading?} (stalled: the ids of the stalled games, see the stall timeout; loading: while a round is loading, the ids of the games that have not said hello after the load, which every window names)
PeerClock 12 host → all but origin {from, t, ahead, need?, safe?, pause?, stalled?, hop?} (stalled: made by the host for a stalled game, safe is the host’s promise for it)
PlayerJoined 13 host → all but the joiner {player: {id, name, ready}}
PlayerLeft 14 host → all {id, name, away?} (away = the connection dropped and the slot is held)
ChecksumRequest 15 host → all {n, at, paused, full?, spread?, chunk?, detail?} (full: false = a light round; absent = full; spread = a full round read a chunk every spread steps, absent = hashed whole at at; chunk = entities a chunk reads, absent = the game’s 200; detail = keep the itemised keys in the game-script state for the dump at a repair)
Authority 16 host → players {n, values: {name: number}}
ResyncSave 17 host → its own game (or the repair’s source player, see “Repair source”) {id, name}
ResyncChunk 18 host → players {id, index, count, sha256, data}
ResyncLoad 19 host → all {id, name, sha256}
Permissions 20 host → all {build, bulldoze, spend, speed, other?}, each "everyone", "hostOnly" or {"only": [ids]}
Ping 21 host → player {n, rtt?: {id: ms}, off?: {id: ms}, ack?}, answered by the player’s session layer; never given to the game (ack: the counter of the last frame the host received from this player, see Catching up)
Kicked 22 host → player {reason, detail?} (the shape of Reject), sealed: the host ended this player’s session
ReportAsk 23 host → players {id, report, by, byName, maxBytes, salt}: build your part of bug report report and send it as transfer id (see “Bug reports”); read by the launcher, never given to the game
ReportBundle 24 host → the player who asked {id, index, count, sha256, data} (the shape of ResyncChunk): the finished bug report; launcher only
ReportBusy 25 host → the player who asked {id, byName}: the request was refused because report id, asked by byName, is still being made; launcher only
Hello 30 game → host {time, save?}
Clock 31 game → host {t, speed, ahead, need?, safe?, pause?, stop?, hop?} (stop: the game stands still at the pause at t; see the pause order)
SpeedRequest 32 game → host {speed} 0 = pause
ChecksumReport 33 game → host {n, time, parts: {name: hash}, authority?: {name: number}, notReady?: [name]}
SaveDone 34 the saving game → host {id, ok, detail?, mods?} (mods: ids of the mods the saved world uses, see “Mod list”); accepted only from the round’s source
ReplayFailed 35 game → host {detail}
ActionRefused 36 game → host {seq, from, detail, applied?, class?, partial?}: the game’s verdict of action from:seq at its stamp (see “Action verdicts”; partial: the engine threw after it had the command, this game’s world may be partly built, see “Repair source”)
Pong 37 player → host {n, w?}, sent by the player’s session layer, not by its game
Leave 38 player → host {}: the player goes on purpose
ResyncUpload 39 player’s launcher → host {id, index, count, sha256, data} (the shape of ResyncChunk): the source player’s save, see “Repair source”; never resent after a resume
ReportRequest 40 player’s launcher → host {note?}: make a bug report (the window’s button); see “Bug reports”
ReportPart 41 player’s launcher → host {id, index, count, sha256, data} (the shape of ResyncChunk): this game’s part of a bug report; never resent after a resume
Action 50 game → host → all but origin (a pause action: all) {stamp, seq, body, category?, from, allowed, pause?, applied?, pauseSeq?, pauseUpto?, hop?} (from, allowed, pauseSeq and pauseUpto set by the host; pauseUpto is the pauseSeq of the last item of the same flush of the pause order, and a game runs on to the flush’s stamp only once it has read that one; category is build, bulldoze, spend or other, default other; see the pause order)
Chat 51 game → host → all but origin {text ≤ 500, from, name, level?} (from, name set by the host; level = info/warn/error makes it a system notice the window shows apart from the chat, kept on relay)
BuildVeto 52 game → host → all but origin {seq, owner, why ≤ 300, from, name} (from, name set by the host). “I cannot build action owner:seq”, sent only by a game that dry-ran a held strict build and found it cannot be rebuilt; silence means yes. The originator cancels its held command, every game drops the queued replay. host? (only the host’s own, cleared on relay): the host refuses the action on every game, its origin included (see the stall timeout).
BuildCommit 53 game → host → all but origin {seq, owner, stamp, from} (from set by the host). The originator of a held strict build made while the game is PAUSED waits a real-time grace for vetoes (the peers dry-run the record and park it, applying nothing), then sends this; every game applies the parked build at stamp (the pause time, or a normal stamp when the game was unpaused meanwhile) and the originator releases its own command. A veto during the grace drops the build everywhere.

A player may send kinds 30 to 41 and 50 to 53. Anything else from a player is dropped, counted and logged once. The host’s own game is player 0 and is trusted like the host.

What the host does with the content of those kinds, beyond decoding them (a message that does not decode is ignored, as always):

Latency stamps (logs only). hop on Clock, PeerClock and Action is {g?, l?, h?, so?, sr?, w?, ro?, rr?}: wall-clock ms since 1970 when the sending mod wrote the line (g), its launcher read it (l), the host handled it (h), the receiving launcher wrote it to to_game.log (w), with the sender’s and the receiver’s clock offset to the host (so, ro: their wall clock minus the host’s) and round trip (sr, rr). The host sets h, so and sr itself and drops w, ro, rr and implausible stamps from a player. The offsets come from the pongs: w is the player’s wall clock at the answer, the host takes the offset of the tightest of the last eight round trips (NTP’s midpoint rule) and reports every player’s in the ping’s off. Nothing decides anything from these fields; an older peer leaves them out and ignores them.

Session rules

Resync round

A resync makes every other game load the host game’s save (and the host’s own game too when HostOptions::host_reloads is true, the library default; the in-game module also reloads it unless FEVERLINK3_HOST_RELOAD=0, see “Host that does not reload” below). It runs in the host’s state machine (resync_state(): Idle, Pausing, Saving, Transferring, Loading, Verifying), is driven by incoming messages and by tick() with the host’s clock, and has one round at a time. Each round has an id (1, 2, …). The session must be started. Every state change is reported to the host’s UI as ResyncProgress; the session stays paused after a failed round.

Pausing       host sets pauseAt (the usual future stamp; a pause already in force is kept; a session that never
              ran: the step the host's game stands at) and broadcasts Session.
              Waits until every ready game in the session's world has a last Clock with t >= pauseAt
              and speed 0. Outsiders are not waited for (see below).                             timeout 120 s
Saving        host → its own game   ResyncSave { id, name: "feverlink3-resync-<id>" }
              host's game → host    SaveDone { id, ok, detail? }                                   timeout 180 s
Transferring  the launcher reads the file and calls resync_file_ready(id, sha256, len); the host
              seals ResyncChunk to every player present at that moment, one call per chunk.      timeout 600 s
Loading       host → participants   ResyncLoad { id, name, sha256 }   (its own game included when host_reloads)
              Every participant's ready flag is cleared and the stored clocks are dropped; the round
              waits for a Hello from each. A host that does not reload keeps its ready flag and its
              clock and sends no Hello.                                                           timeout 600 s
              A participant's Hello { time } moves pauseAt to that time (the save's, the same on
              every game; the furthest when they differ, logged) and drops that game's clock from
              before the load; Session is broadcast again.
Verifying     a paused checksum round: ChecksumRequest { n, at: pauseAt, paused: true }.         timeout 120 s
              Identical: the previous speed resumes (pauseAt cleared), round Idle.
              Different: ResyncFailed, the session stays paused, round Idle.

Outsiders. A ready game whose world is not the session’s yet: every game but the host’s when the session starts with games at different times, and a player that joins (or comes back without its world) a started session. The round’s load replaces its world; its Hello in Loading ends the mark. Until then its clock holds no pause and counts for no stamp (max_clock, future_stamp), it is relayed to the others as a PeerClock flagged stalled with no safe and no need (no game paces itself to it or keeps to its promise), and an Action or BuildCommit of it is refused on every game with a host BuildVeto. The host logs resync <id>: pause <t> waits only for the games in the session's world; not for <names>. UI run 2026-10-21: the start round’s pause was the host’s clock plus the lead and waited 37 s for a guest whose world stood 35 800 game-ms behind to run up to it at x1.

Host that does not reload (host_reloads = false). The host’s game saves the world only to send it and keeps playing its live world, so the host never sees a loading screen; only the players load. The paused checksum in Verifying is then the proof that the host’s live world and the players’ loaded copies agree: a difference fails the round and leaves the session paused exactly as in the reloading case. The checksum covers money, loan, constructions, lines and time, not every part of the simulation, so a world with many vehicles is the place to watch for drift. FEVERLINK3_HOST_RELOAD=0 selects this mode in the in-game module; it is not the default because a live world drifts from a freshly loaded one. With host_reloads the host is sent its ResyncLoad as soon as the save is announced (during Transferring), so it reloads while the players download (HostOptions::host_loads_early, FEVERLINK3_HOST_LOAD_EARLY=0 for the old order: the host’s load goes out together with the players’ after the transfer; lab runs on 2026-10-12 could not tell the two orders apart, see the maintainer’s private notes).

Timeouts. Pausing, Saving and Transferring end the round with ResyncFailed (reason names what was missing). Loading does not fail: the host logs the players that sent no Hello and continues with the ones that did. Verifying fails. Transferring and Verifying timeouts are not in the original design; they exist so a round cannot wait forever.

Bounds. A chunk carries at most 256 KiB (262 144 bytes) of data; a transfer has 1 to 2048 chunks, so a save is 1 byte to 512 MiB. The host cuts the file at exactly 256 KiB, count = ceil(len / 256 KiB), and sends chunks in order from index 0 with the exact size (chunk_len); anything else is refused and logged, nothing is sent. sha256 is 64 hex digits (the host stores it lower case). A base64 chunk frame is about 350 000 bytes, inside the 1 MiB frame limit. A host with no other player connected skips the chunks and goes straight to Loading.

Receiving side (PlayerSession). A ResyncChunk from the host is never passed to the game. The player validates: sha256 is 64 hex digits; 1 <= count <= 2048; 0 <= index < count; 1 <= data.length <= 256 KiB; for a chunk of a transfer already in progress, the same count and sha256 as before. A duplicate index is ignored. One transfer is held at a time; a chunk with another id replaces it. An unfinished transfer is forgotten 10 minutes after its first chunk (tick() or the next chunk). When all chunks are present the player assembles them, computes SHA-256 and only on a match emits PlayerOutput::SaveReceived { id, sha256, bytes }; on a mismatch it forgets the transfer and counts the chunk as dropped. A ResyncLoad is delivered to the game only for an id and sha256 whose save was received intact, otherwise it is dropped.

During a round SpeedRequest from players, and set_speed and pause from the host’s UI, are ignored (logged). Actions are relayed as usual. Periodic checksum rounds do not run because the session is paused.

The pause after the load. The save is taken where the host’s game stands still, which can be a few steps past pauseAt (speed 0 takes effect late), and every game loads it there. A stamp at the old pauseAt is then before the time every game stands at, and is applied late (friend test 2026-10-16: pauseAt 10341800, loaded at 10342400, a build stamped 10341800 was applied 3 steps late on the other game). So the Hellos of the load move pauseAt (above), the pause order’s floor of the old world is dropped, and from Saving to the end of Loading the pause order stamps nothing: the stops the games reported describe the world being replaced (a host loading early no longer counts, so the furthest stop would be a guest’s, behind the save). Held actions of the pause are stamped once every game stands still in the loaded world. The mod does the same on its own side: a game reloaded by a resync treats any pauseAt before its load time as that time (L.loadedAt), for the Session lines from before the load that it reads after it.

While the save travels (Transferring, after resync_file_ready) the host keeps relaying, but a participant’s game is about to replace its world by the save: every message for it except the session layer’s own (Ping, ResyncChunk, ResyncLoad, Kicked) is held (Action, BuildCommit, BuildVeto, PeerClock, Session, Chat, roster lines…) and sealed and sent right after its ResyncLoad, in the order it was made, so the reloaded game reads it after its load line. The same holds for the host’s own game when it reloads after the transfer; a host sent its load early (or one that does not reload) gets everything at once. A round that fails in Transferring sends what was held then; a participant that leaves is forgotten with what was held for it. The launcher streams the save without stopping the host’s loop (launcher/src/session/transfer.rs).

Repair source. A round copies the host’s world, unless the host’s own game reported an action partial (the engine threw while it applied a command the mod handed it: the apply is not transactional, the world may be partly built). Such games are marked until a round finishes. At Saving the host then asks the first ready player that is not marked and announced resync-source-1 (its launcher knows the savegame folders) for its save instead: ResyncSave goes to that player, its SaveDone is the one accepted, and its launcher uploads the file as ResyncUpload frames (not counted against the frame limits) while the round stays in Saving (timeout 180 + 600 s). Complete and matching its SHA-256, the host’s launcher writes it as feverlink3-resync-<id>.sav in the host’s savegame folders (HostOutput::SourceSave) and the round goes on from Transferring exactly as if the host had saved it: the host’s game loads it too, every player is sent it. With no such player (every game marked, no capable player, or a host that does not reload) the host’s world is copied as before and a WARNING line and a chat line say it may still be broken. The source leaving before its save arrived fails the round.

Participants. The players present when the file is announced are the round’s participants: they get the chunks and the ResyncLoad and must say Hello again. A player who connects later waits for the next round (below).

Hot join

PlayerJoined while the session is started starts a round with reason new player (player returned when the joiner is a player coming back that cannot be caught up, see Reconnect); the joiner is a participant, so it receives the save and says Hello after loading it. If a round is already running and has not yet announced its file (Pausing, Saving, Transferring before resync_file_ready), the joiner simply takes part in it. If the file has been announced (Transferring after the announcement, Loading, Verifying) the joiner is remembered and a new round new player starts when the running one ends, whether it succeeded or failed. A hot-join round ignores the cooldown. Before start a join starts nothing.

Resync policy

ChecksumPolicy is a pure struct the host calls on every ChecksumOutcome that completes outside a resync round, with the host’s clock. A round that completes during a resync is judged by the Verifying rule only.

Differing parts Reload when
money never; corrected through Authority
drift parts: vehicles, onboard, persons, stocks, nodeConfigs, script:* at least one of them has differed in every checksum round for 300 s (a round with none of them resets the clock)
everything else two consecutive rounds in which some ordinary part differs, whichever parts (a fork spreads from lane to lane and the per-town townBuildings:<town> parts change with each town; a clean round resets)
ReplayFailed from any game at once
ActionRefused verdicts of one action that differ between games (one applied it, another refused it) at once

Rounds are judged on their non-money parts, so money never hides another difference. A decision resets the counters. Cooldown: a decision made less than 120 s after the end of the previous round (finished or failed) is never dropped: it is remembered (one at a time, the first reason) and the repair starts at the first host tick after the cooldown (ChecksumPolicy::due, reason <reason> (waited for the cooldown)); a full round in which every part was measured and no non-money part differs cancels it (the worlds match). A resync round that starts for another reason (the host’s button, hot join) is the repair and forgets it. One repair runs at a time and at most one starts per cooldown, which keeps resync loops out. The cooldown applies to policy decisions only, not to the host’s button or to hot join. Before 2026-10-23 a decision inside the cooldown was dropped: two real sessions stayed forked for 95 s and 8 min.

Action verdicts

Every game sends its verdict of every build at its stamp: action_applied (applied; for a build injected from the originator’s text, once the native module answered injected) or action_refused (refused, with the reason and its class: inject_refused, decode, engine, company, module). Both are kind 36; the launcher sets applied: true for the action_applied line. A refusal decided at the stamp from the game’s own world is deterministic: identical worlds refuse an action identically, so a refusal alone asks for no repair (lab run 2026-10-18: a town replaced a building during a build’s lead and every game refused the build at its stamp). The host keeps each action’s verdicts (VerdictBook, at most 256 open actions) until every ready game has answered; as soon as two games answer differently the worlds have forked at that action and the policy asks for a resync at once (verdicts of <from:seq> differ, cooldown permitting). A resync forgets every open action. Every round is full. Since full rounds are spread over the steps (below), every regular round is a full one (full_checksum_every default 1; lab 2026-10-18: 2896 entities in 15 chunks of 200 every 3 steps, step max 6.0 ms against the 30 ms budget, no round over 54 ms). ids and every heavy lane are therefore compared in every round, and the policy’s streak, drift and not-ready rules judge every part every round.

No overlap. The host asks for the next regular round only when the previous running round has been reported by every ready game. When the 5 s timer (checksum_seconds) fires while a round is still being read or reported, the next round waits and starts at the first tick after the last report: the interval is 10 s, or the round’s own length when that is longer. The host logs at most once a minute checksum: <k> round(s) waited for the previous one in the last <s> s (longest wait <w> s). A round that is not complete after checksum_round_timeout_seconds (60 s: a game left mid-round or reloaded) is dropped (checksum round <n> dropped: not reported by <names> after <s> s); its late reports are ignored. A resync’s verification round drops a regular round still open from before the reload. Paused rounds and the verification round are asked as before and do not hold up the timer.

The free-id queue part freeq (2026-10-21). In the in-game session controller the native module adds freeq = <epoch>|<n>:<h>|... to each report the game writes: <epoch> is the low 32 bits of the hash of the ECS engine’s free-id queue when the order trace first met the engine (equal on two games that loaded the same world), each <n>:<h> the rolling hash of the engine’s id stream (ids handed out, ids joining the free-id queue, in order) after its n-th op, one every 1024 ops, the newest three. It is not a hash of the round’s step (the native module has no step clock): the host compares it at the newest n every report names (checksum::FREEQ_PART), and leaves it out of the round when a game has no such part (an older game, the trace off, FEVERLINK3_FREEQ_CHECK=0), another epoch or no common n. Equal n with another hash means the next entity ids differ on the games before any build uses them; it is an ordinary part (two rounds in a row: repair).

Light rounds (lab switch and older peers). FEVERLINK3_FULL_CHECKSUM_EVERY=<n> with n > 1 gives back the old split: the heavy lanes (ChecksumPolicy::HEAVY_PARTS: edges, constructions, nodeConfigs, townBuildings with its townBuildings:<town> parts, ids) only in every n-th round (the first round, every paused round and the verification round of a resync are always full). The other rounds carry full: false; a game then leaves the heavy lanes out, and the host drops them from the reports of a light round anyway (an older game that sends everything is not a difference). The policy counts a heavy part only over full rounds: a light round neither grows nor resets a streak or a drift clock that consists of heavy parts only; any other part is judged every round as before.

Spread full rounds. A full round that is not paused carries spread (ChecksumPolicy::spread_steps: the game speed, 1..4 steps, one engine batch). At at every game hashes the light lanes and the ids lane and takes the entity lists of the heavy lanes; then it reads L.CHECKSUM_CHUNK (200) entities of those lists at each step at + k * spread (k = 1, 2, …; at most L.CHECKSUM_MAX_CHUNKS chunks, a bigger world reads more per chunk). The chunks are cut by count, never by time, and each chunk step is a landing mark, so every game reads every entity at the same step: equal worlds give equal parts, and a world that differs (before the stamp or during the round) still differs. A heavy lane is the order-free sum of its entities’ key hashes. The report carries time = at; a game that read a chunk after its step (a batch that missed its mark) reports at plus the lateness, so the host skips that round as time_skew instead of comparing different worlds. Actions are applied at their stamps as always while a round is read; a chunk read after them sees what they built, on every game at the same step. Light rounds, paused rounds and the resync’s verification round are hashed whole (spread absent).

Chunk size and detail (2026-10-19). A report of a spread round carries cost: {entityNs, stepMaxUs, chunk} (the dearer of the costliest chunk’s and the round’s average time per entity on that game). The host sizes the next rounds’ chunk from the dearest cost of each present game’s last three rounds so that the slowest game’s chunk costs about 12 ms of the 30 ms step budget (ChecksumPolicy::chunk_for: 50..4000 entities, at most twice the last one, a change logged as checksum chunk: N entities a step from now on (...)). Every game gets the same number, so the chunks still fall at the same steps and the comparison stays exact. After a compared round that differed, the next rounds carry detail until one is identical: such a round keeps every chunk’s itemised keys in the game-script state (whichever simulation Lua state read it), so the detail_s<sync>_<time>_<player>.txt dump every game writes at the repair is complete and of the same chunks on every game (header complete: detail round N); without a detail round the dump is this state’s own keys, said partial.

A decision is emitted as HostOutput::ResyncRequested { reason } and, with the host option auto_resync (default true), the round starts at once. With auto_resync = false only the request is emitted and the launcher decides, for example by calling start_resync(reason). Nothing is requested before the session starts or while a round is running.

Permissions

Kind 20, host → all, sent whenever the host calls set_permissions and to every joiner right after its Welcome:

{"build": "everyone", "bulldoze": "hostOnly", "spend": {"only": [1, 3]}, "speed": "everyone"}

Each field is a PlayerSet: "everyone", "hostOnly" or {"only": [ids]}; a missing field means everyone. other (renames, colours, line and vehicle settings: the actions of category other) is written only while it restricts something, so the message of a host that never touches it is unchanged. The host’s own game (id 0) is allowed in every set. Players cannot send kind 20 (dropped). A player stores the last message so its mod can refuse locally and roll back.

Speed is enforced by the host: a SpeedRequest from a player not in speed is dropped, counted, and logged once per player. Build, bulldoze and spend are not dropped by the host; see below.

Action category and verdict. An Action gains category: "build", "bulldoze", "spend" or "other" (default "other"; any other value counts as "other"). On relay the host overwrites category with the normalised value and sets allowed: bool from the permissions (other is allowed unless the other set says otherwise). The action is relayed to everyone either way, so every mod sees the same stream and the originating mod, which knows the permissions, can refuse the action and roll back its own change. The host logs the first forbidden action per player and category.

Players and slots

A session has at most max_players games, the host’s own included: the default 8 is the host and seven players. Ids are handed out from 1 in the order players join and are never given to anyone else, not even after the player left: entity keys are <sender>:<seq> and frame nonces are <sender>:<counter>, so a reused id would repeat both. After 65 534 joins in one session the host answers full (no ids left). Display names are unique, case-insensitive; a clash becomes name#id, and the name of a player who is away still counts as taken.

A slot is held for every player that is connected or away. When a held slot is released (the window ran out, or the host’s kick) the others get PlayerLeft without away, so no one keeps a ghost. A player who comes back to a session that has not started keeps the ready it had (its game was not reloaded); in a started session the hot-join round reloads it and it says Hello again. A joiner finding all max_players slots held is rejected with full; the detail counts the away ones, since they may return.

Reconnect

A player whose connection dropped and that returns within the window gets its id, name, slot and permissions back (permissions are sets of ids, so keeping the id keeps them).

first Welcome   resume = { token: 16 random bytes, epoch: 0 }       sealed, so only holders of the session key see it
drop            the host takes the player out of the game, holds the slot, broadcasts PlayerLeft { away: true }
new connection  Join { name, version, proof, resume: { id, mac, last? } }
                mac = HMAC-SHA256(token, "FeverLink3 resume" ‖ nonce ‖ u16 id ‖ UTF8(name) [‖ "last" ‖ u64 last])
host            token valid, slot held (or still bound to a connection the host has not noticed is dead) →
                Welcome { you: id, ..., resume: { token, epoch: epoch + 1 }, resumed: true, catchUp? }
                caught up (see Catching up): the messages it missed, PlayerJoined to the others, Permissions to the
                returner; it stays ready and catches up as a stalled game, no round
                otherwise: PlayerJoined to the others, Permissions to the returner, and in a started session a hot-join
                round (reason `player returned`); the returner is not ready until it says Hello after loading the save
                anything else → a plain join, a new id, `resumed` left out

Catching up

A short drop (a Wi-Fi blip, a Steam relay hiccup) does not reload the returner: its game kept its world, so it is given exactly what it missed and runs on from there (core/src/session/resume.rs, HostSession::resume, begin_catch_up). One mechanism, the same for every message kind:

Leave and removal

Ping and round-trip time

Every ping_seconds (2 s) the host sends each connected player Ping { n, rtt? }. The player’s session layer answers at once with Pong { n }; neither reaches the game. The host measures now − time the ping n was sent on its own clock, keeps up to 8 unanswered pings per player (an older unanswered one is forgotten when a later one is answered) and smooths the samples: the first sets the value, each later one moves it a quarter of the way. rtt in a Ping is the smoothed value in milliseconds, one decimal, of every connected player the host has measured, so every player can show everyone’s round-trip time. The host’s own game is 0. A Pong with an n that was not sent, or was already answered, is ignored.

Steam invites

Implemented in core/src/admission.rs (token, messages, the host’s Gate) and launcher/src/steam_admit.rs (the exchange over Steam); the Steam side is docs/STEAM_TRANSPORT.md, section 14. A pasted join code works as before.

Connect token. What Steam carries: +feverlink3_join <payload>, payload 1:<host steamID64, 17 digits>:<session, 16 hex>[:<ticket, 16 hex>]. 1 is the token format; a reader that finds another number says “update FeverLink3”. session and ticket are 64 random bits each, new for every hosted session. The token without the ticket is public: it is the host’s rich presence connect value (every friend sees it). The overlay invite dialog carries the token with the ticket. Neither ever holds the secret, the password or the key. At most 255 bytes (Steam’s limit for a rich presence value); a token is about 75.

Admission exchange. The friend’s game connects to the host’s Steam ID on virtual port 3; Steam authenticates both accounts and encrypts the connection. One Steam message each: FL3A then UTF-8 JSON, at most 2048 bytes.

friend → host   request   { session: hex16, ticket?: hex16, name, version }
host → friend   waiting   {}                            the host's player is asked
host → friend   admitted  { key: hex of the 32-byte session key }   then the host closes
             or refused   { reason }                    then the host closes

The host decides on the account Steam reports for the connection, never on the name in the request:

The admitted friend joins the session on virtual port 0 with the key it received, through the normal handshake. The key, not the secret, crosses: the friend never learns the secret or the password. Steam’s friend list “Invite to game” carries the public token (rich presence), so such a friend is asked for like a “Join game” click.

Bug reports

The window’s “Report a problem” makes ONE zip that holds the logs of every game in the session, so a player can attach it to a GitHub issue (core/src/session/report.rs, launcher/src/report.rs, launcher/src/live/report.rs). Nothing is sent to the project by itself; the parts travel only between the session’s games, like the world does.

Limits

Not yet specified

Companies. It gets a section here when its state machine lands in Core.