Networking
Tide uses rollback netcode with full server authority. The server is the source of truth, and clients predict ahead of it, rolling back when it disagrees. Since the simulation is deterministic, what goes over the network is mostly inputs.
For how a game starts, hosts and joins matches, see Matches. This page is about what happens underneath.
One path for everything
Single-player is a match too: the machine runs a server itself, and connects to it through a loopback transport. There's no separate offline path, so what works alone works online.
A minimized desktop window or a browser tab in the background keeps running without drawing, so the match goes on for the other players. Browsers slow a hidden page's timers and stop its animation frames, so a hidden page's frames come from a worker's timers instead. Meanwhile it runs a frame when its match is due a tick, at the game's tickRate, and none in between.
A machine that stops simulating can't keep hosting: a program paused in a debugger, a browser that freezes its page, or a phone whose app is suspended can stop answering the other players. With host migration, another player's machine takes over a room's match. Without it, the other players eventually time out; the suspended host's own match resumes from where it stopped if its program comes back. Putting a phone's game in the background does not keep its match running there indefinitely.
What the server sends
The server ticks the one true world. Every tick, it sends each player:
- who joined or left,
- the inputs that changed,
- in full-world games, commands scheduled for that tick, with sender and sequence,
- a hash of that player's permitted world after the tick,
- and, with visibility, the state correction and genuine lifecycle journal needed for that recipient.
An input that didn't change, or didn't arrive, keeps its last value, on every machine. One that changed goes as what changed: a bit for each value that didn't. Players send theirs the same way, each tick's from the tick before. Its ints and enums take as little as they need: a number from -64 to 63 takes a byte, one up to about 8,000 either way two, and so on, and one that changed goes as by how much, so a big number that moves a little takes a byte too. A float that changed goes as the bits that changed, which for a value that moved a little, or a round one, is far fewer than its 32. Nothing is rounded: every value arrives exactly as it was sent, bit for bit.
With visibility, owner input is filtered conservatively when that owner uses hidden entities or restricted components; the recipient's own input remains available. Previous and current inputs are normalized to the same permitted form before delta encoding. Commands execute on the server and clients observe permitted state consequences; their raw targets and payloads are not replayed to projected clients.
Prediction and rollback
Clients start with no input delay in the default rollback mode. A client runs ahead of the server by about half the round trip, plus a small margin, so its inputs arrive in time for the tick they're meant for. The margin is as small as the connection allows: the server says how long each player's inputs waited before their ticks ran, and a client sends them only as early as the network's worst moments need, so that the one that waits least still waits one of the player's frames (a 60th of a second where its frames are shorter, and never more than a tick). All the time an input waits at the server is time later that the other players see it. A frame, because inputs are read and sent a frame at a time: one that came late still gets its input there in time. With no delay, its own input applies at once, and only other players' inputs are guessed, by repeating their last one: for 0.15 seconds past the verified tick, and from there on as if they'd let go, so a wrong guess moves things by what a player does in that long at most, however far ahead a slow connection has the client run (see What other machines guess). In a game without an input there's nothing to get there in time, so clients keep the small lead they joined with.
A client keeps a snapshot of every tick from the last one the server confirmed, the verified tick, to the one it's predicting. When the server's tick arrives:
- If it went as the client guessed, the client only checks its hash against its snapshot.
- If an input was different, the client goes back to the snapshot before that tick, and runs it and the ticks after it again with the right inputs.
- If a hash differs anyway, the client receives an authoritative repair of its permitted world (see Sending worlds).
Below are two machines in one match: the first runs the server, and the second joined its room. Move the pointer over a frame to steer that machine's player. A disc is where the machine drawing it predicts the body is, and the ring of its color is where the server has confirmed it, which a view reads verified.
Both machines are at one desk here, so their packets arrive at once. The slider is player 2's ping, the network's round trip between its machine and the server, there and back: its page holds back what it sends and what it receives, half the trip each, as a slow connection would. It's the number player 2's frame shows. Beside the slider is the least it can be, with nothing held back, which at one desk is next to nothing.
A ping is the network's time alone. A packet that arrives still waits for its machine's next frame to be read, on the way there and on the way back, so an answer takes up to two frames longer than the ping says, on any connection.
- Player 2's own disc still answers at once, however late its packets are. Its ring trails behind: that's the server's answer, a round trip old.
- Player 1 sees player 2 late, by the way there, half the round trip, and guesses in between: steer player 2 in a circle, and its disc in player 1's frame cuts the corners, walking to where it last heard player 2 point.
- Player 1's own disc and ring stay together. Its machine runs the server, so nothing of its own is ever late.
A guess that was wrong is put right the moment the server's answer arrives, which would move a disc all at once. The bodies' position is marked [Position], so a disc glides to where the correction put it (see correction smoothing); a ring is the confirmed world as it is.
Player 1 hosts the match
Player 2 joins it
settings { title = "Predicted and confirmed"; tickRate = 30; maxPlayers = 4; }
scene Main { } // The match's own scene: one machine hosts it, the others join
component Body
{
[Position] float2 position; // Drawn gliding to where a correction put it
Color color;
}
singleton Roster { Map<PlayerID, Entity> bodies; int arrivals; }
// Where this machine's pointer is in the arena, which its input sends.
local singleton Aim { float2 world; bool moved; }
input Steer
{
// A place, not something a player does: what another machine guesses of it stays
[Absolute, Clamp(-3, 3)] float2 target;
[Absolute] bool steering;
Sample(Devices devices, Aim aim)
{
target = aim.world;
steering = aim.moved; // Not before the pointer was ever on this window
}
}
event(PlayerJoined joined) Arrive(mut Roster roster)
{
var first = roster.arrivals % 2 == 0;
roster.arrivals += 1;
roster.bodies[joined.player] = Spawn(
Body { position = float2(first ? -1 : 1, 0), color = first ? Color(0.98, 0.75, 0.14) : Color(0.18, 0.83, 0.75) },
Owner { player = joined.player });
}
event(PlayerLeft left) Depart(mut Roster roster)
{
roster.bodies[left.player].Destroy();
roster.bodies.Remove(left.player);
}
// Each body walks to where its owner points, on every machine alike.
system Follow(Steer steer, mut Body body, Time time)
{
if (!steer.steering) return;
var way = steer.target - body.position;
var step = 2.5 * time.dt;
body.position += Math.Length(way) > step ? Math.Normalize(way) * step : way;
}
view Point(Devices devices, mut Aim aim)
{
Draw.Clear(Color(0.086, 0.086, 0.094));
Draw.SetCamera(float2(0, 0.3), 2.4);
aim.world = Draw.ScreenToWorld(Screen.Flip(devices.pointer.position));
if (Math.LengthSq(devices.pointer.delta) > 0) aim.moved = true;
}
// The ring: where the server has confirmed the body is. It trails what this
// machine predicts by as far as the machine runs ahead of the server.
view Confirmed(verified Body body)
{
Draw.WireCircle(body.position, 0.34, 0.035, body.color);
}
// The disc: the world as this machine predicts it. Its own input applies at
// once, and the other player's is guessed from the last one that arrived.
view Predicted(Body body)
{
Draw.Circle(body.position, 0.2, body.color);
}
view Mine(Body body, with ownership)
{
Draw.Text("you", body.position + float2(0, 0.42), 0.2, Color.white, Anchor.LowerCenter);
}
view Hud(Session session)
{
Draw.SetScreen();
Draw.Text(session.server ? "This machine runs the server" : $"{session.ping} ms round trip to the server",
float2(12, 10), 15, Color.white);
Draw.Text("disc: predicted here ring: confirmed", float2(12, 30), 13, Color(0.64, 0.64, 0.68));
}A client predicts at most a second ahead of the verified tick; beyond that, it waits for the server. The server keeps four seconds of ticks to send again, and a player further behind gets its complete permitted state. A player that was sent the world has every tick since kept for it until it catches up, however long a big world took to arrive, up to 32 seconds; one that can't catch up in that long gets the world again.
When a client gets a world, or returns after its machine stopped while the match went on, it first catches up with the ticks the server has already run. It predicts nothing from the old world while doing so, and samples no input for ticks that can't arrive in time. A short pause skips old inputs too, even when it does not need catch-up. If the pause leaves new inputs behind the server and more confirmed ticks due now than the frame can run, the client catches up before making new predictions. Future inputs still go to the server while the client catches up. Catch-up work is spread over frames: normally no more than eight authoritative ticks a frame, with room for more when frames are consistently longer. A client with a clock for measuring its work also gives that work a budget of half a frame's time, checked between ticks, while running enough ticks to make progress, up to eight mandatory ticks. A machine too slow for the match falls behind and gets the world again; its frames don't keep asking for more work because the last one took longer.
If the server's machine stopped instead, its match clock drops that time while the other machines' clocks went on. A client brings its clock back once two answers prove it is past the prediction window: it has confirmed every tick the server says it ran, and setting its clock from that tick and the answer's round trip would fit within the window. A slow or reordered answer alone doesn't prove that, because its round trip includes that wait. Predictions past the new clock are discarded, and inputs already sent stay with the server; new inputs wait until the clock has reached those ticks again.
Running ticks again is the cost of prediction: another player's input that wasn't what the client guessed means running every tick from there to the one it's predicting again, which on a busy match is most ticks. A client further ahead than its guesses go also runs the ticks past them again each time a tick is confirmed, while another player is doing something: where they let go has moved a tick on. A client measures what its rollbacks cost, and keeps its input delay at the least where they fit: a single rollback may take up to about a frame, the time between its updates, and never less than its budget (4 ms, unless the host says otherwise) nor than a 60th of a second, and running ticks again may take up to that budget an update on average. Past either it delays its input a tick at a time: it still samples inputs as far ahead as before, so they arrive in time, but runs ticks that many behind, predicting less. It takes the delay back a tick at a time once there's been room to spare for half a second, and once no guess has been wrong for a couple of seconds, so a burst of expensive ticks, like a level being built, costs seconds of delay rather than minutes; a client whose guesses are right pays no delay for ticks it never runs again, and the player on the server's own machine, whose inputs go straight in, never has any. A game can ask for delay in place of prediction altogether, with prediction = Prediction.Delay in its settings (see Input delay): then its clients never predict, and run each tick once as the server confirms it, as many ticks behind as that takes. Either way, a tick runs when the client's clock reaches it, however early its frame came, so views blend between ticks at the match's pace.
Snapshots are cheap, which is what makes this work. The world is plain data, with no pointers, and everything past what's in use is zero, so a snapshot is a copy of the bytes in use, and a hash covers only those. Their cost follows what's in the world, not how big it could be.
Sending worlds
Visibility-enabled games encode a canonical value stream for each recipient. It contains permitted entities/components and their reachable values, without server allocator metadata or hidden heap contents. Tick corrections delta-compress that stream from a known verified baseline. A larger repair transfers it in chunks, then adopts it only after validation and the expected hash agree. Hidden interactions can therefore correct public results without sending the hidden cause.
Games without visibility keep the existing page-based path. Each page is either the same as the receiver's or its bytes, with runs of zeros packed small. Where rows or cells resemble those before them, a page goes as their differences too. A player with a similar world first gets page hashes and requests only missing pages. Joining can reuse unchanged pages of the match's deterministic initial world when starting it calls no C (see Calling C). Small worlds and starts that call C transfer whole. Both paths preserve values exactly.
Views and prediction
Views draw the predicted world, blended between its last two ticks (see Views). A tick that's run again after a rollback changes the simulation immediately; tagged fields can smooth how the correction appears.
Predicted and confirmed
Local code tells what's predicted from what the server has confirmed with one word, verified. The match can't say it: it's the same on every machine, and has no "confirmed".
- Before a view's parameter or filter, it reads the world as of the verified tick:
view HealthBar(Health health, verified Health confirmed)gets the health this machine predicts, and the one the server confirmed. See Confirmed state. - Before a local handler's event, it hears the event once its tick is verified:
local event(verified Died died) ShowDeath(...)runs once, late by as far as this machine runs ahead, and is never wrong. Seeverifiedhandlers.
Without it, a view draws the world as this machine predicts it, and a local handler hears each tick as this machine first ran it.
A local handler hears an event once, though the tick that sent it may run again. A client remembers what each tick it hasn't had confirmed delivered: an event the tick sends again stays silent, one only the corrected tick has is heard then, late, and one the correction removed isn't taken back (see Once, under rollback). These records stay beside the world, outside its hash and snapshots. Visibility sessions separately carry authorized genuine lifecycle state for local handlers.
Provisional correction smoothing
Provisional. Match fields marked [Position] or [Rotation] retain local presentation error across a prediction correction and remove it over time. [Position] uses additive error for floats and floating-point vectors. [Rotation] uses the shortest wrapped angular error for a float in radians, or relative rotational error for a quaternion; the tag also selects shortest-path tick interpolation.
Subsequent corrections accumulate with the error still present, which decays exponentially with frame time, with a provisional half-life of 75 ms. Ordinary movement follows the corrected simulation without being low-passed. Untagged fields keep ordinary tick interpolation. entity.Snap() and singleton.Snap() remain immediate and clear the accumulated error. A replacement with no comparable presentation history clears it too, including adoption of a new host's network timeline.
Only presentation keeps this error: match state and collisions correct immediately, and snapshots, hashes and network state are unchanged. Annotated built-in fields can be nested in ordinary structs; correction smoothing through a custom Interpolate type is deferred. See Views for the provisional syntax and contract.
Joining
A player who joins gets the world (see Sending worlds) and a cookie. Joining the same server again with the cookie gets them their PlayerID back, and with it everything the game kept for them.
Host migration
For full-world games, the game's hostMigration setting lets another player continue the match from its last verified world. The server distributes the room key, player identities and cookie hashes needed to reconnect; the relay introduces the new host and the other players. Existing worlds can reuse matching pages. An ended room stays marked ended for five minutes, so a late player does not revive it. See Matches.
Visibility-enabled clients hold partial worlds and cannot currently become authoritative hosts. Tide refuses that promotion; a trusted complete-state handover is not implemented. A full snapshot held only on the old server is not a usable handover after it disappears.
Reliability
Everything is sent again until it's acknowledged. Nothing waits on a reliable stream, so one lost packet never holds up the ones after it.
Commands have a pending queue separate from tick inputs. The server schedules each received command into its next ticks and records its order. A command stays pending until a verified world confirms its sequence; duplicates are ignored. Per-player sequence state is part of the world and follows snapshots and host migration: a sequence for each channel a game's commands are in, and a window of the unordered ids applied. Command handlers use ordinary guard clauses to check the sender's permissions and the payload against match state.
A command too big for a datagram (one with a List<byte>, up to 4 MiB) uploads in 1000-byte chunks, 16 an update, acknowledged and resent under the inputs. In full-world games the server then distributes it before scheduling its tick, whose frame names the transfer. Visibility-enabled games keep command execution server-only and send permitted state consequences instead; a command transfer is not a public broadcast channel.
Transports
Matches run over WebRTC data channels, which neither order nor resend, like UDP, on the web and on desktop alike: desktop games speak WebRTC with an implementation of Tide's own. That's what lets a browser host matches as well as join them, and web and desktop players meet in the same rooms. A desktop host also takes players on a UDP port, Tide's own thin layer, which --connect and Session.Connect reach by address; the web has no UDP, so a browser can't join by address. The protocol above the transport is the same.
Players find each other in rooms, by a code (see Matches), whether they're in a browser or a desktop game. A relay of ours introduces them, passing along what WebRTC needs to connect them, and their packets then go straight between them. Players whose networks can't connect directly go through a TURN server, which carries their packets. Everything to and from the relay is encrypted (wss://), and WebRTC encrypts the match packets. Direct UDP connections by address are unencrypted. On desktop, the relay's encryption is the system's own TLS: Windows' and macOS's, and on Linux OpenSSL's libssl, which nearly every distribution has. A Linux game without it can't reach the relay, so it can't use rooms; it can still --connect by address.
Who sees what
State visibility restricts replication and partial client simulation to permitted entities and whole components. Private scenes supply an enclosing audience, and Visibility groups add live entity and component policies. Hidden state is absent from the client, while the server continues simulating the complete match.
Each tick verifies the recipient's canonical permitted state. Corrections use a known verified baseline, with deltas for changed bytes and chunked transfer when a repair is larger than one frame. Visibility changes supply fresh permitted state and local Shown/Hidden notifications. True Spawned/Destroyed state is journaled authoritatively for recipients permitted at event time; a generic custom-event audience channel is not currently provided. See the visibility page for public-state, task and resident-state constraints.
Coming later
- Dedicated servers with no window and no player of their own, and a public match browser. Room codes and a ready-made lobby are available now.
Collider rewind
Draw remote players with verified Body body to interpolate confirmed positions without extrapolating guessed input around corners. The player's own movement can still use the predicted view. Verified event handlers receive exact event state.
The physics packages offer a read-only query context for hit validation. Enable bounded history with settings { maxRewind = 0.25; } and opt bodies in with Body { rewind = true }. physics.Rewind(controls.viewTime, out var query) prepares one scene for all the queries in that shot; query.Raycast(...), Cast, Overlap and Inside have the current physics query forms. Non-opted bodies use current geometry, so walls still block a historical player. Opt moving doors in when they should also rewind.
With maxRewind enabled, every input has an engine-supplied, read-only ViewTime viewTime. Its tick is the upper confirmed endpoint, alpha is the interpolation fraction, and valid says whether a frame has been presented. The timestamp describes the last frame actually presented, not a future predicted tick. The server treats its wire value as an untrusted claim: it rejects malformed fractions, future ticks, times before that connection's snapshot, times beyond what it advertised and times outside the configured window. Rewind also checks retained physics history and returns false when the requested interval is unavailable. It never silently substitutes current geometry or clamps the time.
The game still validates the shooter, origin, aim, weapon, ammunition and firing cadence from authoritative simulation state. A timestamp does not make a client-supplied hit trustworthy, or prove what a modified client really displayed. See 2D physics and 3D physics.