Tumblefire is a Wild West action game where everything burns, explodes, and ragdolls into the sunset. It supports solo play and drop-in co-op for one friend. Getting multiplayer to feel right in a physics-heavy game meant making hard decisions about authority, prediction, and scope. Here is how we built our netcode in Godot 4.5, what we cut, and what we learned.
Why Host Authority
In a peer-to-peer action game, someone has to be the source of truth. We considered a lockstep model where both clients simulate independently and sync inputs, but Tumblefire relies heavily on Godot's physics engine for ragdolls, fire propagation, and destructible environments. Deterministic physics across two machines is not something Godot guarantees, and trying to force it would have consumed months we did not have.
Instead, we went host-authoritative. All game logic -- damage calculations, fire spread, knockback resolution, enemy AI -- runs exclusively on the host. The client sends inputs and receives the world state. This eliminates an entire class of desync bugs at the cost of the client always being slightly behind. For a two-player co-op game over Steam's relay network, that tradeoff is well worth it.
The Architecture
The host runs the full game simulation. The client runs a thin layer: rendering, input capture, and prediction for a small set of latency-sensitive actions. Every gameplay RPC flows through the host for validation before taking effect.
# HostGameManager.gd -- all game logic lives here
extends Node
@rpc("any_peer", "call_remote", "reliable")
func request_action(action: String, params: Dictionary) -> void:
var sender_id := multiplayer.get_remote_sender_id()
if not _validate_action(sender_id, action, params):
return
# Host executes the action authoritatively
_execute_action(sender_id, action, params)
# Broadcast result to all peers
sync_action_result.rpc(action, params, _get_action_result(action))
@rpc("authority", "call_remote", "reliable")
func sync_action_result(action: String, params: Dictionary, result: Dictionary) -> void:
# Client applies the host-validated result
_apply_action_result(action, params, result)
The client never mutates game state on its own authority. It requests, the host validates and executes, and the result comes back. This pattern repeats across every system: weapon fire, item pickups, door interactions, and knockback.
Knockback was a particularly instructive case. Our original implementation sent a single velocity vector, but that made lag compensation nearly impossible because force magnitude and direction need to be adjusted independently based on network conditions. We refactored the knockback system to use separate force and direction parameters, which let the host apply lag compensation to the force scalar without distorting the direction of the hit.
Client-Side Prediction
Host authority means the client is always one round trip behind reality. For movement, that delay is tolerable -- we interpolate remote positions smoothly enough that it feels natural. But for weapon fire, waiting 50-100ms before your revolver kicks feels terrible. Players notice that instantly.
We solve this with client-side prediction for weapons. When the client pulls the trigger, it immediately plays the muzzle flash, sound effect, and recoil animation locally. The actual hit detection and damage happen on the host. If the host confirms the shot, nothing changes visually. If the host rejects it -- say, the target already died from the host player's shot -- the client gracefully absorbs the mismatch.
# WeaponController.gd -- client-side prediction for fire
func fire_weapon() -> void:
if not _can_fire():
return
# Predict locally: play effects immediately
_play_muzzle_flash()
_play_fire_sound()
_apply_recoil_animation()
# Send fire request to host with timestamp for lag compensation
var fire_data := {
"position": global_position,
"direction": _aim_direction,
"timestamp": Time.get_ticks_msec(),
"weapon_id": _current_weapon.id
}
request_fire.rpc_id(1, fire_data)
@rpc("any_peer", "call_remote", "unreliable_ordered")
func request_fire(fire_data: Dictionary) -> void:
# Host-only: validate and resolve the shot
if not multiplayer.is_server():
return
var sender_id := multiplayer.get_remote_sender_id()
var lag_ms := Time.get_ticks_msec() - fire_data["timestamp"]
# Lag compensation: rewind target positions by lag_ms
var hit_result := _resolve_shot_with_compensation(fire_data, lag_ms)
# Broadcast confirmed result
confirm_fire.rpc(sender_id, fire_data, hit_result)
Because fire is the only action we predict, the surface area for mispredictions is small. In practice, mismatches are rare enough that we do not even need a visible correction -- we just skip the hit marker if the host says the shot missed.
Interest Management
Even with only two players, Tumblefire's levels are large enough that syncing every entity at full rate wastes bandwidth and CPU. We use distance-based interest management to throttle sync rates. Entities near either player update at our full network tick rate of 20Hz. Distant entities -- enemies on the other side of town, burning buildings the players have moved past -- drop to 5Hz.
# NetworkSyncManager.gd -- distance-based interest management
const NEAR_SYNC_RATE := 1.0 / 20.0 # 20Hz for nearby entities
const FAR_SYNC_RATE := 1.0 / 5.0 # 5Hz for distant entities
const INTEREST_RADIUS := 1500.0 # pixels
var _near_timer := 0.0
var _far_timer := 0.0
func _physics_process(delta: float) -> void:
if not multiplayer.is_server():
return
_near_timer += delta
_far_timer += delta
if _near_timer >= NEAR_SYNC_RATE:
_near_timer = 0.0
_sync_entities_in_radius(INTEREST_RADIUS)
if _far_timer >= FAR_SYNC_RATE:
_far_timer = 0.0
_sync_entities_outside_radius(INTEREST_RADIUS)
func _sync_entities_in_radius(radius: float) -> void:
for entity in _tracked_entities:
if _is_near_any_player(entity, radius):
_send_entity_state.rpc(entity.get_path(), entity.get_sync_state())
func _sync_entities_outside_radius(radius: float) -> void:
for entity in _tracked_entities:
if not _is_near_any_player(entity, radius):
_send_entity_state.rpc(entity.get_path(), entity.get_sync_state())
Fire spread deserves special mention. It is one of Tumblefire's signature mechanics, and it is entirely host-authoritative. The FireNetworkSync component replicates fire state to clients, but clients never run the spread simulation. Fire spreading to a new tile, igniting a building, triggering an explosion chain -- all of that is computed on the host and synced as state updates. This avoids the nightmare of two machines disagreeing about which buildings are on fire.
Steam P2P + ENet Fallback
Tumblefire ships with Steam as the primary multiplayer backend, using Valve's relay network for NAT traversal and encrypted transport. But during development, we need fast iteration without Steam running. We also want LAN play to work without a Steam connection. So we built a thin abstraction layer that lets us swap backends without touching game code.
# NetworkBackend.gd -- dual-backend abstraction
class_name NetworkBackend
extends RefCounted
static func create_host(backend: String, port: int = 7500) -> MultiplayerPeer:
match backend:
"steam":
var peer := SteamMultiplayerPeer.new()
peer.create_host(port)
return peer
"enet":
var peer := ENetMultiplayerPeer.new()
peer.create_server(port)
return peer
_:
push_error("Unknown network backend: %s" % backend)
return null
static func create_client(backend: String, address: String, port: int = 7500) -> MultiplayerPeer:
match backend:
"steam":
var peer := SteamMultiplayerPeer.new()
peer.create_client(address.to_int()) # Steam ID as int
return peer
"enet":
var peer := ENetMultiplayerPeer.new()
peer.create_client(address, port)
return peer
_:
push_error("Unknown network backend: %s" % backend)
return null
The rest of the netcode is backend-agnostic. RPCs, sync messages, and connection lifecycle events all go through Godot's standard MultiplayerAPI. Switching from ENet to Steam is a one-line change at session creation. During development, we default to ENet so we can test multiplayer without launching through Steam. In release builds, the game detects whether Steam is running and picks the appropriate backend automatically.
What We Cut: 4-Player Co-op
Early in preproduction, we explored supporting up to four players. On paper it sounded great -- a posse of four gunslingers tearing through a Wild West sandbox. In practice, it would have complicated every system we built.
Interest management with four players means up to four interest zones, quadrupling the worst-case sync budget. The host CPU load scales with the number of entities each player interacts with, and four players spread across the map would push our fire simulation and enemy AI well past budget on minimum-spec hardware. Lag compensation becomes significantly harder when four players are shooting at the same targets. And our drop-in system -- which lets a friend join mid-mission without a lobby -- would need to handle three joiners instead of one, each needing a full world-state snapshot.
We made the call to scope co-op at two players: the host and one friend. That constraint simplified everything. One connection to manage. One remote player to predict and compensate for. One world-state snapshot to serialize on join. The architecture stays clean, the network budget stays tight, and the game feel stays sharp. It was the right call.
Lessons Learned
Host authority is cheaper than you think. We expected the single-source-of-truth model to feel heavy, but for a two-player game it is remarkably simple. The host just runs the game. The client just renders what it is told, plus a thin prediction layer. There is no conflict resolution, no rollback, no state reconciliation beyond weapon fire.
Predict only what the player feels. We initially considered predicting movement, item pickups, and door interactions in addition to weapon fire. Each one added complexity and misprediction surface area. In the end, only weapon fire needed prediction because it is the only action where a single frame of latency breaks the feel. Everything else tolerates a round trip just fine.
Separate force from direction early. Our knockback refactor taught us that compound physics values -- a single velocity vector encoding both magnitude and direction -- are hostile to lag compensation. Splitting them into independent parameters gave us clean knobs to tune without side effects. We now apply that principle to every networked physics interaction.
Design for two, not "up to N." Scoping co-op at exactly two players was not a compromise. It was a design decision that made every networking problem simpler by a constant factor. If your game does not need four players, do not build the infrastructure for four players. The complexity is not linear.
Tumblefire's multiplayer is not trying to be a competitive esport netcode solution. It is trying to let you and a friend set a town on fire together and have it feel seamless. Host authority, minimal prediction, and a hard two-player cap let us deliver that experience without drowning in networking complexity.