Tumblefire is a Wild West action game about blowing things up with purpose. Every mission is handcrafted -- teardown-style objectives where you need to collapse a specific wall, sever a bridge support, or bring down an entire saloon before the sheriff arrives. That means destruction cannot be cosmetic. Buildings need to break in structurally convincing ways, load-bearing walls need to matter, and a stick of dynamite at the base of a support beam needs to bring the second floor down with it. Here is how we built the system that makes all of that work in Godot 4.5 with Jolt Physics.

Destruction as Core Design

We made an early decision that destruction would not be a visual effect layered on top of gameplay -- it would be the gameplay. That meant we could not fake it. A building that looks like it collapses but is actually just swapping to a pre-broken mesh does not give players the information they need to plan their approach. If the player puts three sticks of dynamite under the east wall, they need to see the east wall fail, the roof sag, and the whole structure fold in that direction. The physics have to be real enough that players can reason about them.

Since every mission in Tumblefire is handcrafted rather than procedurally generated, we have the luxury of tuning each building's structural behavior by hand. We know exactly which walls the player is supposed to target, and we can set up the structural connections to reward clever demolition while still allowing brute-force approaches.

Structural Integrity

Every destructible building in Tumblefire carries a StructuralIntegrity component that tracks its overall health and decides when the structure is compromised. The core idea is simple: each building part contributes to a whole, and when enough parts are destroyed, the building enters a compromised state that triggers cascade evaluation.

class_name StructuralIntegrity
extends Node

signal integrity_changed(new_ratio: float)
signal structure_compromised()
signal structure_collapsed()

@export var total_health: float = 100.0
@export var compromise_threshold: float = 0.3

var current_health: float
var _is_compromised: bool = false
var _connected_parts: Array[DestructiblePart] = []

func _ready() -> void:
    current_health = total_health

func register_part(part: DestructiblePart) -> void:
    _connected_parts.append(part)
    part.part_destroyed.connect(_on_part_destroyed)

func apply_damage(amount: float, impact_point: Vector3) -> void:
    current_health = maxf(current_health - amount, 0.0)
    var ratio := current_health / total_health
    integrity_changed.emit(ratio)

    if ratio <= compromise_threshold and not _is_compromised:
        _is_compromised = true
        structure_compromised.emit()
        _evaluate_cascade()

The 30% threshold is the magic number. When a building drops to 30% structural integrity, we flag it as compromised and run cascade detection. That threshold came from playtesting -- lower values made buildings feel like they were made of rubber, higher values made them collapse too eagerly from stray bullets. Thirty percent means you have to commit real firepower to bring something down, but once you cross that line, the collapse feels inevitable.

Connection Types

Not every part of a building matters equally. A load-bearing pillar is not the same as a window frame, and destroying a hanging lantern should not compromise the roof. We model this with three connection types that define how damage propagates between parts:

enum ConnectionType {
    STRUCTURAL,  ## Load-bearing: pillars, main beams, foundations
    SURFACE,     ## Walls, floors, roof panels
    WEAK,        ## Decorations, furniture, trim
}

class PartConnection:
    var target: DestructiblePart
    var type: ConnectionType
    var propagation_factor: float

    func _init(t: DestructiblePart, c: ConnectionType) -> void:
        target = t
        type = c
        match c:
            ConnectionType.STRUCTURAL:
                propagation_factor = 0.8
            ConnectionType.SURFACE:
                propagation_factor = 0.4
            ConnectionType.WEAK:
                propagation_factor = 0.0

STRUCTURAL connections carry 80% of damage to their neighbors. Destroy a load-bearing pillar and everything connected to it through structural links takes heavy damage. SURFACE connections -- walls and floors -- pass along 40%, enough that blowing out a wall weakens the adjacent walls but does not instantly level the building. WEAK connections propagate nothing. Shoot a chandelier and you get a satisfying crash without any structural consequence. This three-tier system gives us fine-grained control during level design: we mark up each building's connections in the editor, and the propagation math handles the rest.

Cascade Collapse

The cascade system is where buildings go from "damaged" to "collapsing." When the structure hits the compromise threshold, we walk the connection graph looking for load-bearing parts that have been destroyed. If a structural part is gone and everything above it depended on it for support, those parts lose their footing and collapse.

func _evaluate_cascade() -> void:
    var unsupported: Array[DestructiblePart] = []

    for part in _connected_parts:
        if part.is_destroyed:
            continue
        if not _has_structural_support(part):
            unsupported.append(part)

    if unsupported.is_empty():
        return

    for part in unsupported:
        part.begin_collapse()
        _propagate_damage_to_neighbors(part)

    # Re-evaluate -- collapsing parts may unsupport others
    await get_tree().physics_frame
    _evaluate_cascade()

func _has_structural_support(part: DestructiblePart) -> bool:
    for conn in part.connections:
        if conn.type == ConnectionType.STRUCTURAL and not conn.target.is_destroyed:
            if conn.target.global_position.y < part.global_position.y:
                return true
    return part.is_grounded

func _propagate_damage_to_neighbors(collapsed_part: DestructiblePart) -> void:
    for conn in collapsed_part.connections:
        if conn.target.is_destroyed:
            continue
        var damage := collapsed_part.mass * 9.8 * conn.propagation_factor
        conn.target.apply_damage(damage)

The recursive re-evaluation after each physics frame is critical. When a second-floor beam collapses, the third-floor walls it was supporting become unsupported in the next pass. This creates the rolling, progressive collapse that makes destruction feel organic rather than scripted. Jolt Physics handles the actual rigid body motion once parts are flagged for collapse -- we just need to tell it which parts are no longer connected to the structure.

Material-Based Debris

When a part is destroyed, it needs to shatter into debris that matches its material. A wooden wall should splinter into planks and sawdust. A stone chimney should crack into heavy chunks. We define material properties in a resource and use them to drive both the visual and physical behavior of debris.

class_name MaterialDebrisConfig
extends Resource

@export var material_type: StringName  ## "wood", "stone", "glass", "metal"
@export var debris_scenes: Array[PackedScene] = []
@export var fragment_count_range: Vector2i = Vector2i(3, 8)
@export var fragment_mass_range: Vector2 = Vector2(0.5, 3.0)
@export var eject_force: float = 5.0
@export var lifetime: float = 30.0

static var WOOD := preload("res://data/debris/wood_debris.tres")
static var STONE := preload("res://data/debris/stone_debris.tres")
static var GLASS := preload("res://data/debris/glass_debris.tres")

func spawn_debris(origin: Vector3, impact_dir: Vector3, parent: Node3D) -> void:
    var count := randi_range(fragment_count_range.x, fragment_count_range.y)
    for i in count:
        var fragment: RigidBody3D = debris_scenes.pick_random().instantiate()
        parent.add_child(fragment)
        fragment.global_position = origin + _random_offset()
        fragment.mass = randf_range(fragment_mass_range.x, fragment_mass_range.y)

        var force := impact_dir * eject_force * fragment.mass
        force += Vector3(randf_range(-1, 1), randf_range(0.5, 2), randf_range(-1, 1)) * eject_force
        fragment.apply_impulse(force)

        _schedule_cleanup(fragment, lifetime)

func _random_offset() -> Vector3:
    return Vector3(randf_range(-0.3, 0.3), randf_range(-0.1, 0.3), randf_range(-0.3, 0.3))

func _schedule_cleanup(fragment: RigidBody3D, delay: float) -> void:
    var tween := fragment.create_tween()
    tween.tween_interval(delay - 2.0)
    tween.tween_property(fragment, "modulate:a", 0.0, 2.0)
    tween.tween_callback(fragment.queue_free)

Wood fragments are light, numerous, and scatter wide. Stone chunks are heavy, fewer, and drop close to the impact point. Glass produces many tiny shards with high initial velocity. Each material config is a .tres resource file that designers can tweak without touching code. The 30-second lifetime with a 2-second fade at the end keeps the scene from drowning in debris while avoiding the jarring pop-out that instant cleanup produces.

Fire Meets Structure

Fire is not just a visual hazard in Tumblefire -- it is a demolition tool. Burning parts lose structural integrity over time, which means a fire that starts on a ground-floor wall can eventually bring down the whole building if left unchecked. We integrate this by ticking damage on any part that has an active fire attached to it.

When fire spreads to a STRUCTURAL part, the slow tick of heat damage eats away at integrity until the compromise threshold triggers cascade evaluation. The result is that players can set a strategic fire and wait, or use dynamite for immediate results. Both paths are valid, and they interact -- a half-burned support beam takes much less dynamite to finish off. The fire system feeds damage through the same apply_damage path as explosions and bullets, so all the cascade and debris logic works identically regardless of the damage source.

Performance Considerations

A building shattering into forty rigid bodies while simultaneously running cascade evaluation is not cheap. We use two strategies to keep things manageable.

First, distance-based destruction detail. Buildings near the camera get full physics simulation -- every fragment is a Jolt rigid body with proper collision. Buildings far from the camera use a simplified path: fewer fragments, no inter-fragment collision, and pre-baked collapse animations for the structural cascade. The crossover distance is tuned per mission, but it typically sits around 30 meters.

Second, debris cleanup is aggressive. The 30-second lifetime caps the maximum number of physics bodies in the scene at any given time. During heavy destruction sequences, we also throttle fragment counts -- if the active rigid body count exceeds our budget, new debris spawns with reduced fragment counts. Players rarely notice the difference because it only kicks in during the most chaotic moments when there is already too much happening on screen to count individual splinters.

Lessons Learned

Building this system taught us a few things we did not expect.

Playtest the threshold relentlessly. The 30% compromise number took dozens of iterations. Too low and buildings feel indestructible. Too high and a stray shotgun blast levels the whole town. Every building in every mission has been playtested to make sure the threshold produces satisfying collapse behavior for the intended approach without making accidental collapses trivial.

Let Jolt do the heavy lifting. Early versions of the system tried to animate collapse procedurally with tweens and keyframes. Switching to Jolt Physics for all rigid body motion after the cascade triggers was a turning point. The physics engine produces emergent behavior that looks natural in ways we could never hand-animate -- walls leaning, beams sliding, debris piling up realistically.

Debris is content, not just code. We initially generated debris procedurally by slicing meshes at runtime. The results were technically correct but visually bland. Switching to hand-authored debris fragment scenes per material type made destruction look dramatically better. The artist time was worth it.

Connection markup is level design. The connection graph between building parts is not a programming task -- it is a design task. Once we gave level designers direct control over which connections are STRUCTURAL, SURFACE, or WEAK, the quality of destruction scenarios improved immediately. They could set up buildings where the "right" approach was satisfyingly clever without us writing any special-case code.

Destruction in Tumblefire is not a particle effect. It is a structural simulation that respects load-bearing connections, propagates damage through physical relationships, and produces debris that behaves like the material it came from. Getting it right took more iteration than any other system in the game, but when a player puts dynamite in exactly the right spot and watches a two-story saloon fold in on itself -- that is the moment that makes it all worth it.