← Back to Blog

When I set out to build Tumblefire, I had one clear goal: create a game where burning down an entire Old West town felt as satisfying as it sounds. But more importantly, I wanted every playthrough to tell a different story.

The Fire Problem

Making fire spread convincingly in a physics-based game is harder than it looks. You can't just make everything catch fire instantly—that's boring. But you also can't make it too slow, or players lose interest. The magic happens somewhere in between, where the fire feels alive and unpredictable.

Here's the core fire propagation system I built in Godot:

extends Node3D

const FIRE_RADIUS = 2.5
const BASE_IGNITION_CHANCE = 0.3
const SPREAD_CHECK_INTERVAL = 0.5

var active_fires = []

func _spread_fire(fire_position: Vector3):
    var nearby_objects = get_nearby_objects(fire_position, FIRE_RADIUS)

    for obj in nearby_objects:
        if obj.is_flammable and not obj.is_burning:
            # Calculate ignition chance based on distance and material
            var distance = fire_position.distance_to(obj.global_position)
            var distance_factor = 1.0 - (distance / FIRE_RADIUS)
            var final_chance = BASE_IGNITION_CHANCE * distance_factor * obj.material_flammability

            if randf() < final_chance:
                obj.ignite()
                active_fires.append(obj)

func get_nearby_objects(position: Vector3, radius: float) -> Array:
    var space_state = get_world_3d().direct_space_state
    var query = PhysicsShapeQueryParameters3D.new()
    var sphere = SphereShape3D.new()
    sphere.radius = radius
    query.shape = sphere
    query.transform.origin = position

    var results = space_state.intersect_shape(query)
    return results.map(func(r): return r.collider)

The key insight here is the distance_factor—objects closer to the fire are more likely to catch, but there's still randomness. This creates those "oh no" moments where the fire jumps to an unexpected building.

Emergent Stories Through Systems

The real magic happens when systems interact. Fire doesn't just spread—it affects physics objects. Burning wood weakens. Structures collapse. Players quickly learn that setting fire to a building's support beams is way more effective than trying to burn the whole thing down.

I didn't explicitly program "burn the supports to collapse the building." That emerged from combining:

Players figured out the rest. And that's the point.

The Wind System

Adding wind was a game-changer—literally. Now fire spreads faster downwind, which means players have to think about positioning. Do you start the fire upwind and let it spread naturally? Or do you set multiple fires to create a pincer movement?

func apply_wind_factor(base_chance: float, fire_pos: Vector3, target_pos: Vector3) -> float:
    var direction_to_target = (target_pos - fire_pos).normalized()
    var wind_direction = get_current_wind_direction()
    var wind_alignment = direction_to_target.dot(wind_direction)

    # Wind alignment ranges from -1 (against wind) to 1 (with wind)
    # Map this to a multiplier between 0.3 and 2.0
    var wind_multiplier = lerp(0.3, 2.0, (wind_alignment + 1.0) / 2.0)

    return base_chance * wind_multiplier

This tiny addition created so many interesting scenarios. Players started checking wind direction before every heist. Some wait for the wind to change. Others adapt their plans on the fly.

Lessons Learned

Building Tumblefire taught me that emergent gameplay isn't about complex AI or branching storylines. It's about creating simple, interacting systems and trusting players to find the fun.

The best moments in Tumblefire are ones I never explicitly designed. Like when a player accidentally set themselves on fire while trying to burn down a saloon, panicked, ran into a general store, and burned down half the town by accident. That's a story they'll remember—and tell their friends about.

That's what emergent gameplay is all about.


Check Out Tumblefire