How Multiplayer Works in Froggy Works - Mon, Jul 13, 2026
Multiplayer in Froggy Works does not use a traditional game server that owns the world and sends the result to everyone else. Every player runs a complete copy of the simulation locally.
The cloud server still has a job, but it is deliberately small. It creates sessions, assigns join codes, relays WebRTC signaling, tracks membership, elects a coordinator, and stores recovery snapshots. It does not run farms or tick game worlds.
This was an important distinction for me. I wanted local input to feel like local input, even when the game was online. Waiting for a server round trip before a frog starts walking would make the controls feel worse for everyone, including the person who created the farm.
A complete world on every device
Each participant owns a local FfServer and FfClient. The server half advances the world, and the client half handles input, prediction, presentation, and rendering. Windows, macOS, and iOS use the native C/C++ implementation. The browser runs the same C core compiled to WebAssembly.
With one player, online play is almost the same as offline play. There is no peer connection to maintain. Commands are applied to the local simulation, and the result is rendered immediately.
When another player joins, WebRTC data channels are opened between the peers. The architecture does not suddenly switch to streaming a host's world. Both players continue running their own worlds. They exchange commands and compact owned-unit state so those worlds converge.
The basic command path is:
- Predict and apply the command locally.
- Render the local result immediately.
- Wrap the command with the player's stable owner ID.
- Send it to the coordinator over WebRTC.
- Validate and relay it to the other peers.
- Apply it to each peer's local simulation.
The local command router is intentionally ordered this way:
status = ff_core_client_predict_locally(
client, command, &track_predicted_sequence);
status = ff_server_apply_command_json(local_server, command_wire);
status = ff_core_client_drain_local_server(client);
if (status == FF_OK && client->peer_local_replication != 0) {
status = ff_core_client_send_command_wire(client, command_wire);
}
The network send comes after the local application. The player does not wait for WebRTC, SignalR, the coordinator, or another simulation tick before seeing the action.
Commands instead of world dumps
Gameplay messages use a small envelope around the normal Froggy Works command format:
{
"kind": "peer-command",
"ownerId": "stable-player-id",
"command": { "$type": "Move", "TargetX": 312, "TargetY": 208 }
}
There are matching peer-join and peer-leave messages. This makes player lifecycle explicit and, importantly, idempotent. Receiving the same join twice does not spawn two characters, and receiving a repeated leave after the character is already gone does nothing.
The command body is the same command system used by local play. Planting, building, harvesting, weather changes, movement, possession, and the rest of the game do not need separate multiplayer versions. Multiplayer is mostly a transport around the existing simulation boundary.
Native clients parse and validate the envelope before touching the world. The coordinator also checks that the ownerId in the message matches the identity assigned to that WebRTC peer:
if (!parsePeerMessage(commandJson, message) ||
message.kind != PeerMessageKind::Command ||
message.ownerId != stableOwner ||
!server.applyPlayerCommand(stableOwner, message.commandJson)) {
return;
}
transport.broadcastCommand(commandJson);
That check does not make the game cheat-proof. Peer-local multiplayer deliberately trusts the clients, and a modified client can cheat. It does stop a peer from casually putting another player's identity into an otherwise valid packet.
For movement, each player owns the feel of their controlled character and publishes compact position updates at 20 Hz. Other peers apply those updates to their local copy. Most actions are event-like commands, so there is no reason to continuously serialize and transmit the whole farm for normal gameplay.
The coordinator is not a simulation host
One peer is called the coordinator, but the name can be misleading. It is not the only peer running the game, and the guests are not remote renderers.
The coordinator is a stable ordering and relay point. That matters when two players try to claim the same thing at the same time. Possessing another unit is the obvious example. Sitting on furniture has the same problem because a seat can only have one occupant.
Those contested operations take a different route. A guest predicts presentation where appropriate, but does not commit the operation to its local simulation first. It sends the request to the coordinator, which validates and relays the accepted result. Ordinary commands still use the immediate local-first path.
The split is small in the core:
if (command->type == FF_CMD_POSSESS) {
return true;
}
if (command->type != FF_CMD_USE_STRUCTURE) {
return false;
}
return def != NULL && def->seats.len > 0;
This avoids turning every action into a network round trip just because a few actions need a single ordering decision.
WebRTC for gameplay, SignalR for introductions
The cloud server uses SignalR on /webrtchub, but gameplay does not travel through that hub. SignalR handles the control plane: session creation, joining, protocol negotiation, SDP offers and answers, ICE candidates, membership changes, and coordinator election.
Once peers have negotiated WebRTC, the game data moves over four named data channels:
createDataChannel("ctl", reliable);
createDataChannel("cmd", reliable);
createDataChannel("snap", reliable);
createDataChannel("pos", unreliableUnordered);
ctl carries welcome and lifecycle data. cmd carries the peer command envelopes and is reliable and ordered. snap and pos remain for compatibility with the older host-mirror path. A current peer-local guest already has a full simulation, so it ignores those legacy streams during normal play.
The server relays signaling only between peers in the same session. It also limits signaling payload sizes and rates before forwarding them. TURN credentials are generated server-side when direct peer connectivity is not possible, so no long-lived provider secret is shipped in the game.
Checkpoints and host migration
Local-first does not mean disposable. The coordinator uploads a full recovery save when the session is created, every 12 seconds, when players join or leave, and during shutdown when possible.
The cloud stores that snapshot with the permanent farm code and membership data. It never simulates the snapshot. Before storage, it removes player characters whose stable identities are no longer connected, so a stale disconnect cannot be resurrected by a later checkpoint.
When the last player leaves, the farm becomes paused rather than deleted. Joining the same code later restores the snapshot into a local simulation, and the returning player becomes the new coordinator.
Migration while people are still connected uses the same idea. The signaling server elects a surviving peer and sends it the latest recovery snapshot. The promoted guest ends its guest stream, loads the snapshot as a recovery fence, installs the coordinator broadcaster, and continues:
activation.migrationSnapshotJson = std::move(snapshotJson);
activation.migration = true;
if (guestActive) {
guestBridge->endStream();
}
hostBridge->activate(activation, *runtime, &unitEntityId, &error);
roleValue = P2PSessionRole::Host;
Because every peer was already running a complete world, migration is mostly a routing change. The promoted peer is not being upgraded from a passive renderer into a game server.
The tradeoff
This design optimizes for responsive cooperative play, not competitive security. The server cannot fully validate a simulation it never runs. Two clients can also diverge if a command is dropped, interpreted differently, or applied in a different order, which is why the protocol has strict message validation, stable owner identities, coordinator ordering for contested actions, position convergence, and durable recovery snapshots.
The useful result is that online play still feels local. The network carries intent and recovery data, not every frame of the game. The cloud can disappear from the hot gameplay path without disappearing from session management or persistence.
That is the main idea behind Froggy Works multiplayer: run the game everywhere, synchronize the decisions that matter, and keep the server out of the way unless it has a specific job to do.