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:
- Spreads based on material properties
- Responds to wind and air currents
- Consumes fuel over time
- Can be blocked by walls or extinguished by water
- Creates smoke that follows realistic physics
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
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:
- Chunk-based updates: Only update chunks near players or active fires
- Dirty rectangles: Track which regions changed and only update those
- Lower resolution: The simulation grid can be coarser than the visual grid
- Update budgeting: Spread expensive updates across multiple frames
- 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:
- Pour oil to create fire trails
- Break walls to create backdrafts
- Use water buckets to extinguish flames
- Set diversionary fires to distract guards
- Accidentally burn down the entire town (oops)
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:
- Simple rules create complex behavior
- Visual feedback is criticalâplayers need to understand fire spread patterns
- Performance profiling is essentialâmy first version ran at 5 FPS
- Bugs can be featuresâsome "unrealistic" behaviors were actually more fun
- Save frequentlyâtesting fire spread means destroying your test scenes a lot
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