- Godot
- RTS
- Strategy
- Physics
Let It Cook
A real-time strategy game where you act as a fire deity spreading fire through settlements while managing wind, embers, and firefighters. You create disasters for fun.
In Let It Cook, you spread a wildfire to destroy human settlements. You start from a single fragile spark in an environment fortified by walls, obstacles, and enemies. As the settlement escalates its defenses, your survival depends on more and more strategic planning.
To win, you must read the village layout, anticipate emergency responders, manage an Ember economy, and master the physics of wind and fire.
In short, you will create disasters for fun.
A simple UI for Let It Cook, showing Ember resources, alarm meters, and building layout.
High-Level System Architecture
During early prototyping, game.gd was a single monolithic script that handled everything from mesh spawning and camera panning to fire physics and villager AI state machines. As propagation rules and human countermeasures grew in complexity, this collapsed under its own weight.
To decouple game logic from presentation, we refactored the simulation into five isolated, single-responsibility domains:
flowchart TD
subgraph Presentation["Presentation & Input"]
UI["HUD & Telemetry"]
INPUT["Player Input"]
FORECAST["Forecast & Compass"]
end
subgraph Simulation["Simulation Core"]
GM["Game Master"]
FIRE["Fire Simulation"]
WEATHER["Weather System"]
end
subgraph AI["Autonomous AI"]
DIRECTOR["Encounter Director"]
ACTORS["Responders & Villagers"]
end
subgraph World["World & Audio"]
LEVEL["Level Builder"]
AUDIO["Sound Manager"]
end
INPUT --> GM
GM --> FIRE & WEATHER & DIRECTOR
DIRECTOR --> ACTORS
FIRE & WEATHER & ACTORS -->|"Modify State"| LEVEL
FIRE --> FORECAST & UI & AUDIO
Fire Simulation
In classical cellular-automata fire games, spread is purely probabilistic: every frame, every burning tile rolls randf() < spread_chance. If the roll succeeds, the adjacent tile ignites.
This randomness ruins tactical strategy. A well-planned attack can fail repeatedly due to bad luck, while a single stray spark can prematurely incinerate half the map without player input.
We took inspiration from RimWorld, where heat accumulates deterministically, forcing players to manage insulation and airflow rather than praying to an RNG god. In Let It Cook, fire spreads through an accumulated thermal pressure model.
Thermal Pressure Equation & Passive Dissipation
You can think of each building as a sponge. It steadily soaks up heat radiated by burning neighbors until its meter hits 100%. Wind acts like a fan on the flames: blowing toward the building increases heat transfer, while blowing away starves it.
An unburned structure dst catches fire when its normalized heat H_dst reaches 1.0:
When P_incoming > 0: H_dst(t + Δt) = min(1.0, H_dst(t) + R_mat · P_incoming · Δt)
When P_incoming = 0: H_dst(t + Δt) = max(0.0, H_dst(t) - K_decay · Δt)
Where:
R_matis the material transfer coefficient (0.035for timber cottages,0.13for dry pine trees).K_decayis the passive cooling rate (0.025/s).P_incomingis the combined thermal power radiating from all burning neighbors:
P_incoming = Σ [ W(src → dst) · D_wet · D_rain ]
Also, thermal pressure is leaky. Materials do not retain absorbed energy forever. If ambient wind swings away or an adjacent fire burns to ash before igniting its neighbor, an unburned structure at 80% heat cools completely back to zero within 40 seconds. You cannot “pre-heat” buildings across the map and abandon them, as spreading fire demands sustained pressure.
The directional wind weight W measures how well the wind points toward the target using a vector dot product:
W = max(0.2, 1.0 + (dir(src → dst) · wind_dir) · wind_strength · 0.8)
- Wind aligned (
dot = +1.0): Flames blow directly toward the target, boosting heat transfer up to2.6x. - Wind opposed (
dot = -1.0): Flames are pushed away, choking transfer down to the0.2floor. D_wetsuppresses heat transfer by up to90%if the target was recently splashed with water (dst.wetness > 0.05).D_raindampens all global heat transfer by58%whenever active rain is falling.
flowchart LR
SRC["Burning House"] & WIND["Wind Vector"] --> GAP["Distance & Wind"] --> WET["Wetness Damping"] --> DST["Target Structure"] -->|"Ignition Threshold"| IGNITE["Active Fire"]
Building Spacing: How Far Fire Can Jump
From an isometric perspective, players need to know instantly: “Can fire jump this gap?”
To make map readability immediate and predictable, building placement is tuned around three clear distance rules:
| Distance Band | Can Fire Jump It? | Tactical Outcome |
|---|---|---|
| Connected | Always. Bridges reliably across calm air with zero wind. | Free propagation. Fuel chains sustain themselves with no Ember expenditure. |
| Conditional | Only with wind. Needs favorable wind alignment or multiple converging heat sources. | Opportunistic. Wait for ambient wind to shift, or spend 1 Ember on a Wind Gust to force a fire. |
| Firebreak | Never passively. Wide roads, stone plazas, and canals form hard barriers. | Active bypass. Must use a Wind Gust or detonate explosive barrels. |
var max_dist := WIND_GUST_RANGE if inside_active_gust else (TREE_RADIUS if dst.kind == "tree" else HOUSE_CONDITIONAL_RADIUS)
if dist > max_dist:
continue
var align: float = (to / dist).dot(wind_dir)
var w: float = maxf(0.2, 1.0 + align * wind_strength * WIND_BIAS)
if not inside_active_gust and dist > HOUSE_CONNECTED_RADIUS:
var falloff: float = 1.0 - (dist - HOUSE_CONNECTED_RADIUS) / (HOUSE_CONDITIONAL_RADIUS - HOUSE_CONNECTED_RADIUS)
w *= maxf(0.0, falloff * (0.3 + align * 0.7))
if inside_active_gust:
w *= 3.5
if dst.wetness > 0.05:
power *= maxf(0.08, 1.0 - dst.wetness * 0.9)
if rain_active:
power *= 0.42
if power > 0.0:
dst.heat = minf(1.0, dst.heat + rate * power * delta)
elif dst.heat > 0.0:
dst.heat = maxf(0.0, dst.heat - HEAT_DECAY * delta)
Topological plan of Village Fields, demonstrating road firebreaks, connected building clusters, and water choke points.
Tactical Aerodynamics: Wind Gusts & Trajectory Forecasting
Ambient wind in Let It Cook is ever changing. It wanders continuously, shifting direction every 22 to 34 seconds. While ambient wind creates passive bias across the map, the player’s primary weapon is the Local Wind Gust.
flowchart LR
DRAG["Aim Drag"] --> CONE["Cone Projection"] --> FORECAST["Heat Forecast"] --> RELEASE["Aim Release"] --> BLAST["Heat Blast"]
The 60-Degree Wind Cone
Holding right-click on an active flame and dragging casts an aiming cone into the world:
- Origin: Center of the selected burning structure.
- Direction: Aim vector projected onto the ground plane (
XZplane). - Reach: 60° arc (±30° half-angle) extending up to 10.5 m—far enough to bridge across normal 7.6 m road firebreaks.
- Cost & Duration: Costs 1 Ember; creates a high-velocity localized heat blast for 4.0 seconds (6.0 second cooldown).
Active Wind Gust aiming: casting a 60-degree cone from a burning cottage with real-time heat links and status forecasts.
Airborne Ember Convection: Turning Wind into an Anti-Personnel Weapon
Wind Gusts, when fired through an active flame, blasts a shower of burning embers downwind across the 60° cone.
Any humans caught in the blast starts an ignition check. The closer they stand to the fire source, the higher their risk:
var closeness := 1.0 - clampf(dist / WIND_GUST_RANGE, 0.0, 1.0)
var chance := (0.35 + closeness * 1.15) * delta
if randf() < chance:
body.ignite()
This gives wind two tactical utility: other than using gusts to jump firebreaks, you can blast enemies directly. Lighting them on fire before they reach water turns it into an offensive weapon.
Forecasting for Wind Gust
To prevent wasting precious Embers on failed guesses, forecasting runs a forward simulation every frame while aiming. For every structure inside the cone, it projects whether the 4-second blast will deliver enough heat to reach ignition:
# scenes/fire_simulation.gd
if sim_gust_active:
if inside_sim_gust:
var gained_heat := rate * power * WIND_GUST_DURATION
var projected_heat := cur_heat + gained_heat
if cur_wetness >= 0.35 and projected_heat < 1.0:
result.status = "Wet"
result.detail = "Wet (%d%%) — Resists gust heat" % int(cur_wetness * 100)
result.color = Color(0.25, 0.65, 1.0)
elif projected_heat >= 1.0:
result.status = "Likely"
result.detail = "WILL IGNITE during Wind Gust!"
result.color = Color(0.2, 1.0, 0.35)
result.will_ignite_in_gust = true
else:
result.status = "Needs Heat"
result.detail = "Gains +%d%% heat in gust (reaches %d%%)" % [int(gained_heat * 100), int(projected_heat * 100)]
The game provides immediate visual feedback before releasing the click:
- Green (“Likely”): Guaranteed to ignite unless responders intervene.
- Blue (“Wet”): Roof is soaked and will resist the blast.
- Yellow (“Needs Heat”): Absorbs heat, but needs another gust or supporting flame to ignite.
Resources: Ember Economy, Anti-Stall & “Last Spark”
Disaster games fall flat if resources are unlimited or so tight that one mistake softlocks the run. We engineered a closed-loop economy around Embers:
flowchart LR
subgraph Gains["Ember Inflow"]
BURN["Structure Burned"]
SHAMAN["Shaman Disrupted"]
BARREL["Barrel Detonated"]
STALL["Anti-Stall Trigger"]
end
POOL["Ember Pool"]
subgraph Spends["Ember Outflow"]
GUST["Wind Gust"]
IGNITE["Manual Ignite"]
SPARK["Last Spark"]
end
Gains --> POOL --> Spends
Strategic Economy Rules
- Strict 5-Ember Ceiling: You start with 2 Embers and can never hold more than 5. This prevents hoarding resources to overwhelm the endgame.
- Asymmetric Ability Pricing:
- Wind Gust (1 Ember): Cheap and flexible, encouraging frequent tactical steering of ongoing fires.
- Manual Ignite (3 Embers): Intentionally expensive. Bypassing a firebreak to spark a fresh front from scratch costs more than half your maximum reserves.
- Anti-Snowballing: Burning down a target awards +1 Ember, but rewards have an 8-second internal cooldown. Triggering a massive chain reaction will not instantly overflow your meter.
- The Anti-Stall Valve: If you drop to 0 Embers while your fires are dying, waiting 12 seconds grants a single emergency Ember. This eliminates unwinnable softlocks without rewarding passive play.
The “Last Spark” Second Chance
When firefighters douse your final burning building, you do not instantly lose. The structure enters an 8-second Smoldering state:
# scenes/game.gd
if last_spark_available and count_burning == 0 and last_spark_candidate != null:
last_spark_available = false
last_spark_active = true
last_spark_house = last_spark_candidate
last_spark_house.enter_smoldering(8.0)
SoundManager.play_sfx("smolder")
_post_hint("LAST SPARK! One house is smoldering! Click to reignite (1 Ember) before it dies!")
Escalating Defenses: How Settlements Fight Back
Human defenses escalate across four threat tiers managed by the EncounterDirector:
flowchart TD
P1["Spark Phase"] -->|"Villager Alert"| P2["Alarm Phase"]
P2 -->|"Multiple Fires"| P3["Escalation Phase"]
P3 -->|"Heavy Spread"| P4["Inferno Phase"]
1. Lookouts and Civilian Panic
Civilians (VoxelVillager) walk through patrol lanes. When smoke or flame enters their line of sight, an alert icon (!) pops over their head, and the settlement alarm begins to rise.
2. Bucket Brigades: Using Real Chores
At 25% alarm, civilians grab buckets and form suppression lines. To make their responses feel real, villagers have to walk through physical chores:
flowchart LR
POND["Water Source"] --> WALK["Haul Water"] --> FIRE["Burning House"] --> POND
WALK -.->|"Surrounded by Fire"| PANIC["Flee in Panic"]
WALK -.->|"Radiant Heat"| BURN["Catch Fire"]
Because villagers must walk real paths, settlement geography directly impacts their effectiveness:
- Distance delays defense: Buildings far from the pond burn down before bucket carriers can make the round trip.
- Breaking their courage: If a carrier is surrounded by 3 or more burning buildings, they panic, drop their bucket, scream
"TOO HOT!!", and flee.
A central canal serves as an impassable firebreak and water source as civilians haul buckets across bridges to suppress the blaze.
Bucket brigade logistics: villager carrying water from the pond to an active fire.
3. Firefighters: Tunnel Vision as a Gameplay Mechanic
Firefighters arrive by road, armed with high-pressure hoses that steadily extinguish burning structures.
If an enemy simply targets the nearest fire every frame, it twitches erratically, constantly turning back and forth whenever a closer ember sparks up. To give the player something predictable to play around, firefighters lock onto their chosen target for a mandatory 6 seconds:
func _pick_target() -> VoxelHouse:
if target_house != null and is_instance_valid(target_house) and target_house.state == VoxelHouse.State.BURNING:
if commit_timer > 0.0:
return target_house
Once their water tank runs dry (after 5 seconds of continuous spraying), they break off and walk to the nearest water source to refill, leaving another window of vulnerability.
Water Physics: Insulation vs. Boiling Evaporation
In Let It Cook, water plays two different roles depending on whether a structure is already on fire:
- Pre-Ignition Insulation (
wetness): Splashed onto an unburned roof, water acts as heat armor. It cuts incoming radiant heat by up to 90% (D_wet) and dries out slowly over time. - Post-Ignition Dousing (
water_soaked): Once a structure catches fire, surface wetness no longer matters. Putting out an active blaze requires delivering a cumulative threshold of 3.2 units of water (WATER_TO_EXTINGUISH). A bucket splash delivers 0.55, while a firefighter hose sprays 0.32/s.
But active fires fight back. The blaze constantly boils off delivered water at 0.10 units per second:
if water_soaked > 0.0 and state == State.BURNING:
water_soaked = maxf(0.0, water_soaked - delta * WATER_SOAK_DECAY)
This evaporation turns suppression into a physical rate-of-work contest. When a firefighter’s 5-second tank runs dry and they retreat to refill, the pause allows the fire to boil off their accumulated progress. If suppression pauses for more than a few seconds, the building’s water tally drops back to zero, preserving your burning front.
4. Flammable Humans: The Panic Cascade
Every human in the simulation inherits from the CharBurn component. If exposed to radiant heat nearby, brushed by burning wreckage, or caught in an ember-carrying Wind Gust, they catch fire themselves:
- While burning, their state is overridden by pure panic.
- They yell and sprint at 1.5x speed along chaotic, unpredictable paths.
- They spread fire through contact. A burning civilian running down an alleyway acts as an accidental torch, igniting dry cottages and catching fellow humans on fire.
- Nearby firefighters or bucket carriers can extinguish them with a water burst, creating emergent rescue dynamics that also distract them from saving buildings.
The panic cascade in action: a burning civilian sprints screaming down the alleyway while a responder attempts suppression.
5. The Shaman Rain Ritual
In Level 2 (Town), the northeastern courtyard features a Shaman mini-boss. As the settlement alarm rises, the Shaman begins a 10-second telegraphed altar ritual.
If completed, settlement-wide rainfall begins, cutting fire spread rates by 58% and dousing active flames. Divine powers cannot target human flesh directly, so you cannot click the Shaman. Instead, you must steer a fire front across town canals and alleys to torch the wooden altar, interrupting the ritual and earning an Ember reward.
Shaman summoning a rain downpour.
Tactical Environmental Props: Explosive Barrels & City Firebreaks
Settlements incorporate non-building props that reward route planning over brute force:
flowchart LR
HEAT["Incoming Heat"] --> BARREL["Barrel"] --> PRIMED["Primed State"] --> BLAST["Explosion"]
BLAST --> HOUSES["Ignite Buildings"]
BLAST --> HUMANS["Knockback Responders"]
BLAST --> CHAIN["Detonate Barrels"]
BLAST --> EMBER["Ember Gain"]
Explosive Barrels as Route Objectives
Barrels have to be ignited organically by spreading fire. When internal heat reaches 1.0, a barrel primes, and then explodes.
The blast immediately ignites nearby timber, knocks down responders, and awards an Ember, blowing open previously defended alleys.
Explosive barrel in its primed state.
City Stone Firebreaks
In Level 3 (City), central districts are built from non-combustible stone. Stone structures are completely impervious to heat and barrel blasts, acting as permanent walls to divide the map into isolated districts. To destroy the city, players must carefully route fire through narrow market stalls and other structures.
Level Topology: Moving from Voxel Grids to Spline Geometry
Early prototypes used discrete tile grids for level generation. While straightforward to compute, grid maps felt a bit boring.
For the overhaul, we developed Village Fields, a level that replaces grid tiles with organic 3D spline ribbons and procedural height maps:
Overview of Village Fields, showing continuous dirt lane splines, organic pastures, and relaxed building pad placement.
Procedural Geometry Techniques in Godot
- Continuous Ribbon Road Meshes: Roads are defined as splines in coordinates. The code calculates normal offsets along the curve, generating ribbon meshes with lighter dirt centers and dark grassy verges.
- Organic Muddy Ponds: Water bodies are procedural polygons with mud skirting and perimeter markers for bucket refills.
- Plot Relaxation Algorithm: Rather than snapping buildings to grid cells, building plots are placed along road tangents. A spatial relaxation algorithm nudges neighboring plots apart until each pad satisfies a 3×3 meter footprint and maintains a gap.
Enjoy the disaster you created!
Audio Engineering
Sound design communicates disaster escalation without causing audio clipping when too many houses are on fire:
flowchart LR
FIRE["Active Fires"] --> AMB["Crackle Ambience"]
WEATHER["Active Rain"] --> RAIN["Rain Ambience"]
SFX["Game Events"] --> SFX_BUS["SFX Bus"]
AMB & RAIN & SFX_BUS --> MASTER["Master Bus"]
-
Dynamic Fire Crackle Ambience: When 1 house burns, you hear a faint crackle. When 15 houses burn, you should hear a roaring inferno. The game tracks the active burning structure count
N, and based on that, crackle volume scales smoothly from-80 dB(silent), leveling off smoothly to0 dB(full roar):Volume_dB = clamp(-36.0 + 12.0 · log10(max(1, N)), -80.0, 0.0) -
Voice Pooling & Anti-Clipping: Only 8 sound effects can play at the same time. Any extra sounds are ignored or queued. Each scream is pitched slightly higher or lower (±8%) so they don’t overlap on the exact same audio frequency.