← Back to Blog

Building Tumblefire required fire to be more than a particle effect. Every wooden beam, curtain, and piece of furniture needed to burn realistically. Players should be able to set strategic fires, watch them spread unpredictably, and deal with the consequences. The solution? Pixel-based physics using cellular automata.

Why Pixel Physics?

Traditional particle systems look great but they're purely visual. They don't actually interact with the world. The fire system needed to:

Pixel physics (or cellular automata) treats the world as a grid where each cell has properties and follows simple rules. Think Conway's Game of Life, but for fire propagation.

The Core System

At its heart, the fire system uses a 2D grid overlaid on the game world. Each pixel stores:

class_name PixelCell

enum CellType {
    AIR,
    FIRE,
    SMOKE,
    WOOD,
    STONE,
    WATER
}

var type: CellType = CellType.AIR
var temperature: float = 20.0  # Celsius
var fuel: float = 0.0          # How much burnable material
var velocity: Vector2 = Vector2.ZERO
var lifetime: float = 0.0

The simulation runs in steps, updating each cell based on its neighbors:

func simulate_step(delta: float):
    var new_grid = grid.duplicate(true)

    for y in range(height):
        for x in range(width):
            var cell = grid[x][y]
            match cell.type:
                CellType.FIRE:
                    update_fire_cell(x, y, cell, new_grid, delta)
                CellType.SMOKE:
                    update_smoke_cell(x, y, cell, new_grid, delta)
                CellType.WOOD:
                    update_wood_cell(x, y, cell, new_grid, delta)
                CellType.WATER:
                    update_water_cell(x, y, cell, new_grid, delta)

    grid = new_grid

Fire Propagation Rules

Fire spreads through heat transfer. Each burning cell radiates heat to neighbors, and if neighboring cells are flammable and hot enough, they ignite:

func update_fire_cell(x: int, y: int, cell: PixelCell, new_grid: Array, delta: float):
    # Consume fuel
    cell.fuel -= BURN_RATE * delta
    if cell.fuel <= 0:
        # Fire dies, becomes smoke
        new_grid[x][y].type = CellType.SMOKE
        new_grid[x][y].temperature = cell.temperature * 0.8
        new_grid[x][y].velocity = Vector2(0, -SMOKE_RISE_SPEED)
        return

    # Spread heat to neighbors
    var heat_spread = cell.temperature * HEAT_TRANSFER_RATE * delta

    for offset in [Vector2i(-1,0), Vector2i(1,0), Vector2i(0,-1), Vector2i(0,1)]:
        var nx = x + offset.x
        var ny = y + offset.y

        if not is_valid_pos(nx, ny):
            continue

        var neighbor = grid[nx][ny]

        # Heat transfer
        neighbor.temperature += heat_spread

        # Ignition check
        if neighbor.type == CellType.WOOD and neighbor.temperature >= IGNITION_TEMP:
            new_grid[nx][ny].type = CellType.FIRE
            new_grid[nx][ny].fuel = neighbor.fuel

    # Fire rises and spreads
    if randf() < FIRE_SPREAD_CHANCE:
        try_spread_fire(x, y - 1, new_grid)  # Prefer upward spread
Performance Tip: Only simulate cells that are "active" (fire, smoke, or within a certain radius of activity). Skip static cells like stone walls.

Wind and Environmental Effects

Wind makes fire feel alive. The system uses a simple vector field that can be modified by the player's actions or level design:

var wind_field: Array = []
var global_wind: Vector2 = Vector2.ZERO

func apply_wind_to_fire(x: int, y: int, cell: PixelCell):
    var local_wind = wind_field[x][y] + global_wind

    # Fire spreads faster in wind direction
    var wind_direction = local_wind.normalized()
    var target_pos = Vector2(x, y) + wind_direction

    if randf() < local_wind.length() * WIND_SPREAD_MULTIPLIER:
        try_spread_fire(int(target_pos.x), int(target_pos.y), grid)

    # Smoke also follows wind
    if cell.type == CellType.SMOKE:
        cell.velocity += local_wind * delta

Opening a door or window can create air currents that dramatically change fire behavior. This leads to emergent gameplay where players might accidentally create a chimney effect.

Smoke Simulation

Smoke is just as important as fire for gameplay. It rises, blocks vision, and can suffocate players:

func update_smoke_cell(x: int, y: int, cell: PixelCell, new_grid: Array, delta: float):
    # Smoke rises
    cell.velocity.y = lerp(cell.velocity.y, -SMOKE_RISE_SPEED, 0.1)

    # Apply movement
    var new_pos = Vector2(x, y) + cell.velocity * delta
    var target_x = int(new_pos.x)
    var target_y = int(new_pos.y)

    # Dissipate over time
    cell.lifetime += delta
    if cell.lifetime > SMOKE_LIFETIME or not is_valid_pos(target_x, target_y):
        new_grid[x][y].type = CellType.AIR
        return

    # Move to new position if empty
    if grid[target_x][target_y].type == CellType.AIR:
        new_grid[target_x][target_y] = cell
        new_grid[x][y].type = CellType.AIR
    else:
        # Spread horizontally if blocked
        for offset_x in [-1, 1]:
            if grid[x + offset_x][y].type == CellType.AIR:
                new_grid[x + offset_x][y] = cell
                new_grid[x][y].type = CellType.AIR
                break

Rendering the Simulation

The simulation runs on the CPU, but rendering needs to be GPU-accelerated. A shader samples the grid and renders it efficiently:

shader_type canvas_item;

uniform sampler2D simulation_texture;
uniform vec3 fire_color_1 = vec3(1.0, 0.8, 0.0);
uniform vec3 fire_color_2 = vec3(1.0, 0.2, 0.0);
uniform vec3 smoke_color = vec3(0.2, 0.2, 0.2);

void fragment() {
    vec4 cell_data = texture(simulation_texture, UV);

    // cell_data.r = temperature (normalized)
    // cell_data.g = cell type
    // cell_data.b = fuel/intensity

    float temp = cell_data.r;
    float type = cell_data.g;

    if (type > 0.4 && type < 0.6) {  // Fire
        vec3 fire_color = mix(fire_color_2, fire_color_1, temp);
        COLOR = vec4(fire_color, cell_data.b);
    } else if (type > 0.6 && type < 0.8) {  // Smoke
        COLOR = vec4(smoke_color, 0.6 * cell_data.b);
    }
}

Optimization Tricks

Running a full cellular automata simulation at 60 FPS is challenging. Here's what I learned:

  1. Chunk-based updates: Only update chunks near players or active fires
  2. Dirty rectangles: Track which regions changed and only update those
  3. Lower resolution: The simulation grid can be coarser than the visual grid
  4. Update budgeting: Spread expensive updates across multiple frames
  5. Spatial hashing: Use a quadtree or grid to quickly find active cells

Material Properties

Different materials burn differently. I store material data in a resource:

class_name BurnableMaterial
extends Resource

export var ignition_temp: float = 300.0    # Celsius
export var max_fuel: float = 100.0
export var burn_rate: float = 10.0
export var heat_output: float = 500.0
export var smoke_production: float = 5.0

# Examples:
# Wood: ignition 300°C, high fuel, medium smoke
# Paper: ignition 230°C, low fuel, low smoke
# Oil: ignition 210°C, very high heat output
# Fabric: ignition 250°C, fast burn rate

Player Interaction

The best part? Players can interact with this system in creative ways:

These emergent behaviors come naturally from the simulation. I didn't script specific scenarios—the physics creates them.

Lessons Learned

Building this system taught me:

Want to try it? Start small. Build a simple falling sand simulation first, then add fire. The principles are the same, but sand is more forgiving to debug.

Pixel physics opened up gameplay possibilities I never anticipated. Players use fire in ways I never designed for, and that's exactly what I wanted. If you're building a destruction-based game, consider pixel simulation—it's more accessible than you think.

← Back to Blog