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:

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.