Tumblefire is a Wild West action game where fire is the central mechanic. Buildings catch fire, flames jump between structures, smoke chokes out sightlines, and entire towns can burn to the ground mid-firefight. That vision only works if fire looks good and runs well -- and "runs well" means maintaining 60 FPS with over 100 objects burning simultaneously. Here is how we built the system that makes that possible in Godot 4.5.
The Performance Challenge
Our early prototype was simple: every burning object spawned its own GPUParticles3D node with a full fire shader, an OmniLight3D for glow, and an AudioStreamPlayer3D for crackling. It looked fantastic with three or four fires on screen. At twenty fires, we dropped to 40 FPS. At fifty, the game was a slideshow.
The problem was not any single fire -- it was the combinatorial cost. Each fire added draw calls for particles, a shadow-casting light, audio mix overhead, and per-frame signal emissions updating intensity values across the network. We needed to cut costs at every layer without sacrificing the feeling that the world was on fire. The solution was a multi-layered approach: distance-based LOD for visuals, pooled particle systems, throttled signals, and a host-authoritative network model that offloads simulation from clients entirely.
Multi-LOD Fire Rendering
The biggest single win was accepting that most fires on screen do not need full particle effects. Players fixate on the fire in front of them. Fires two blocks away just need to look plausible. Fires beyond the horizon do not need visuals at all.
We split fire rendering into four LOD tiers based on distance from the active camera:
# FireLODController.gd -- attached to every FireComponent
extends Node3D
enum FireLOD { FULL, REDUCED, IMPOSTER, SIMULATION_ONLY }
const LOD_FULL_DIST := 15.0
const LOD_REDUCED_DIST := 30.0
const LOD_IMPOSTER_DIST := 50.0
var _current_lod: FireLOD = FireLOD.FULL
var _fire_particles: GPUParticles3D
var _imposter_sprite: Sprite3D
var _fire_light: OmniLight3D
func update_lod(camera_pos: Vector3) -> void:
var dist := global_position.distance_to(camera_pos)
var new_lod: FireLOD
if dist < LOD_FULL_DIST:
new_lod = FireLOD.FULL
elif dist < LOD_REDUCED_DIST:
new_lod = FireLOD.REDUCED
elif dist < LOD_IMPOSTER_DIST:
new_lod = FireLOD.IMPOSTER
else:
new_lod = FireLOD.SIMULATION_ONLY
if new_lod == _current_lod:
return
_current_lod = new_lod
_apply_lod()
func _apply_lod() -> void:
match _current_lod:
FireLOD.FULL:
_fire_particles.amount = 64
_fire_particles.visible = true
_imposter_sprite.visible = false
_fire_light.visible = true
FireLOD.REDUCED:
_fire_particles.amount = 16
_fire_particles.visible = true
_imposter_sprite.visible = false
_fire_light.visible = false
FireLOD.IMPOSTER:
_fire_particles.visible = false
_imposter_sprite.visible = true
_fire_light.visible = false
FireLOD.SIMULATION_ONLY:
_fire_particles.visible = false
_imposter_sprite.visible = false
_fire_light.visible = false
At the FULL tier (under 15 meters), the player gets 64 particles, dynamic lighting, and full audio. Between 15 and 30 meters, we drop to 16 particles and kill the light -- the particle glow is enough at that distance. Between 30 and 50 meters, we replace particles entirely with an animated Sprite3D imposter that billboards toward the camera. Beyond 50 meters, the fire still spreads and deals damage in the simulation, but we render nothing at all. The global fire manager calls update_lod on all active fires once per frame, batched to avoid per-fire camera lookups.
Particle Pooling
Even with LOD, creating and destroying GPUParticles3D nodes at runtime caused hitches. GPU particle systems have setup cost -- uploading the process material, allocating GPU buffers, warming up the emission curve. Spawning one mid-gameplay produces a visible frame spike.
We solved this with ParticlePoolManager, which pre-allocates a fixed pool of GPU particle systems at level load and hands them out on demand:
# ParticlePoolManager.gd -- pre-allocated GPU particle pool
extends Node
const POOL_SIZE := 128
var _available: Array[GPUParticles3D] = []
var _active: Dictionary = {} # fire_id -> GPUParticles3D
func _ready() -> void:
for i in POOL_SIZE:
var particles := preload("res://vfx/fire_base.tscn").instantiate()
particles.emitting = false
particles.visible = false
add_child(particles)
_available.append(particles)
func checkout(fire_id: StringName) -> GPUParticles3D:
if _available.is_empty():
# Steal from the lowest-priority active fire
return _steal_lowest_priority()
var p := _available.pop_back()
_active[fire_id] = p
return p
func release(fire_id: StringName) -> void:
if fire_id not in _active:
return
var p := _active[fire_id]
p.emitting = false
p.visible = false
_active.erase(fire_id)
_available.append(p)
func _steal_lowest_priority() -> GPUParticles3D:
var farthest_id: StringName
var farthest_dist := 0.0
var cam_pos := get_viewport().get_camera_3d().global_position
for id in _active:
var dist := _active[id].global_position.distance_to(cam_pos)
if dist > farthest_dist:
farthest_dist = dist
farthest_id = id
return _active[farthest_id] if farthest_id else null
We allocate 128 particle systems at startup. When a fire needs particles, it checks one out from the pool. When the fire dies or drops to IMPOSTER LOD, it releases the system back. If the pool runs dry -- which happens during massive town fires -- we steal from the farthest active fire, since that one is most likely at a low LOD tier anyway. This approach eliminated all particle-related frame spikes in our profiling.
Signal Throttling
Each FireComponent tracks an intensity value between 0.0 and 1.0 that changes as fire grows, sustains, and dies. Other systems need this value: the LOD controller scales particle count, the audio system crossfades between small-fire and inferno loops, the damage system ticks health. Our first implementation emitted intensity_changed every _process frame. With 100 fires, that was 6,000 signal emissions per second doing almost nothing -- intensity rarely changes by a perceptible amount between frames.
We throttled fire intensity signals to roughly 7 Hz with a dirty flag:
# FireComponent.gd -- throttled intensity broadcasting
extends Node3D
signal intensity_changed(new_intensity: float)
const BROADCAST_INTERVAL := 0.15 # ~7 Hz
const INTENSITY_EPSILON := 0.02
var intensity := 0.0
var _last_broadcast_intensity := 0.0
var _broadcast_timer := 0.0
func _process(delta: float) -> void:
_update_intensity(delta)
_broadcast_timer += delta
if _broadcast_timer >= BROADCAST_INTERVAL:
_broadcast_timer = 0.0
if absf(intensity - _last_broadcast_intensity) > INTENSITY_EPSILON:
_last_broadcast_intensity = intensity
intensity_changed.emit(intensity)
func _update_intensity(delta: float) -> void:
# Fuel consumption, oxygen, material flammability
intensity = clampf(intensity + _growth_rate * delta, 0.0, 1.0)
The combination of a time gate (0.15 seconds) and a minimum-change threshold (0.02) cut signal emissions from ~6,000/sec to under 500/sec with no perceptible loss in visual or audio responsiveness. Child modules like the visual and audio controllers interpolate locally between broadcasts, so transitions still look smooth.
Network-Synchronized Fire
Tumblefire is a multiplayer game, and fire spread is gameplay-critical -- it determines building destruction, escape routes, and area denial. We could not afford to have clients disagree about which buildings were on fire. Our solution was a fully host-authoritative model: clients never run spread logic. They only render what the host tells them to render.
# FireNetworkSync.gd -- host-authoritative fire state
extends MultiplayerSynchronizer
@export var synced_intensity := 0.0
@export var synced_is_burning := false
@export var synced_spread_targets: Array[NodePath] = []
var _fire_component: FireComponent
func _ready() -> void:
_fire_component = get_parent()
if multiplayer.is_server():
_fire_component.intensity_changed.connect(_on_intensity_changed)
else:
# Clients only apply visuals from synced state
set_process(true)
func _on_intensity_changed(new_intensity: float) -> void:
synced_intensity = new_intensity
func _process(_delta: float) -> void:
if not multiplayer.is_server():
_fire_component.apply_visual_intensity(synced_intensity)
if synced_is_burning and not _fire_component.is_burning:
_fire_component.ignite_visual_only()
# Host-only: evaluate spread to neighbors
func server_evaluate_spread(neighbors: Array[FireComponent]) -> void:
if not multiplayer.is_server():
return
for neighbor in neighbors:
if neighbor.is_burning:
continue
var spread_chance := _fire_component.intensity * 0.3
if randf() < spread_chance:
neighbor.ignite()
synced_spread_targets.append(neighbor.get_path())
FireNetworkSync uses Godot's MultiplayerSynchronizer to replicate intensity and burning state. The host runs the full simulation -- fuel consumption, oxygen modeling, spread probability -- and pushes the results to clients. Clients receive the synced values and apply them purely to visuals and audio through FireComponent's child modules. This means a client with a bad connection might see a fire ignite 100ms late, but it will never disagree with the host about which buildings are burning. It also means clients spend zero CPU on spread logic, which helps lower-spec machines maintain frame rate.
Smoke as a Gameplay Mechanic
Fire produces smoke, and we wanted smoke to matter beyond aesthetics. In Tumblefire, volumetric smoke is a gameplay system. The FireEffectsIntegrator automatically spawns heat zones and smoke volumes around active fires. Smoke drifts with the wind system and accumulates in enclosed spaces.
The gameplay impact is significant: NPCs inside dense smoke suffer an 80% visibility reduction at the center of the volume and their detection radius drops to 30% of its normal value. This means players can use fire tactically -- set a building ablaze upwind of a patrol route, and the drifting smoke creates a traversable stealth corridor. The smoke density falls off from center to edge, so positioning within the cloud matters.
We implemented this as an Area3D that queries overlapping NPC perception components and applies a multiplier based on distance from the smoke center. The global fire manager coordinates all active smoke volumes to avoid redundant overlap calculations, and smoke zones are only created for fires at FULL or REDUCED LOD -- distant fires skip smoke gameplay entirely since no NPC near the player would be affected.
Results
The combined system -- four-tier LOD, pooled particles, throttled signals, host-authoritative networking, and gameplay-integrated smoke -- gets us where we need to be. In our worst-case stress test (120 simultaneous fires across a full town with four connected players), the host maintains 60 FPS on our target spec and clients sit comfortably above 70. The breakdown of where we saved the most:
- LOD tiers reduced average draw calls per fire from 4 to 1.2 across a typical scene, since most fires are at REDUCED or lower.
- Particle pooling eliminated all fire-related frame spikes during ignition and extinguishing.
- Signal throttling cut per-frame signal overhead by roughly 90%, from ~6,000 emissions/sec to under 500.
- Host-authoritative networking removed all spread simulation cost from clients and eliminated fire state desync entirely.
The architecture also turned out to be surprisingly extensible. When we added the dynamite mechanic -- which instantly ignites everything in a radius -- we did not need to change the fire system at all. We just called ignite() on every FireComponent in range and the existing LOD, pooling, and network layers handled the rest. That kind of resilience is what you get when each layer solves exactly one problem and delegates everything else.
If you are building a fire system in Godot -- or any particle-heavy effect that needs to scale to high counts -- the lesson we keep coming back to is: budget for the worst case from day one. It is far easier to design a pooling and LOD system before you have 50 scripts depending on raw particle nodes than it is to retrofit one after the fact.