From 7742ad231b569db05546ef0cbba801daa64f3c6b Mon Sep 17 00:00:00 2001 From: chowjiaming Date: Thu, 13 Aug 2026 17:11:27 -0400 Subject: [PATCH 01/10] chore: gitignore local docs, agent files, and skills lock Keep design notes and agent config off main, same as DESIGN.md and TASKS.md. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 1b5e147..e8511e9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ node_modules AGENTS.md DESIGN.md TASKS.md +docs/ +.agents +skills-lock.json From 1fe4f5a9c758eaf9bff3aeba166a445266d915b1 Mon Sep 17 00:00:00 2001 From: chowjiaming Date: Thu, 13 Aug 2026 17:18:45 -0400 Subject: [PATCH 02/10] feat: add steam cell and parallel heat plane SoA heat follows ttl: paint/swap/ignite/extinguish stay in sync so later conduction can land on a 1-byte Cell grid. --- src/renderer.rs | 1 + src/world.rs | 63 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/renderer.rs b/src/renderer.rs index 72b52d1..904c2d6 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -35,6 +35,7 @@ pub fn draw(world: &World, frame: &mut [u8]) { put(frame, i, 255, 32 + t * 4, 16); } Cell::Wood => put(frame, i, 118 + j / 2, 72 + j / 2, 36), + Cell::Steam => put(frame, i, 216 + j / 2, 220 + j / 2, 232), } } } diff --git a/src/world.rs b/src/world.rs index 6d1670c..b9e7c3b 100644 --- a/src/world.rs +++ b/src/world.rs @@ -13,6 +13,7 @@ pub enum Cell { Stone = 3, Fire = 4, Wood = 5, + Steam = 6, } impl Cell { @@ -24,6 +25,7 @@ impl Cell { 3 => Some(Cell::Stone), 4 => Some(Cell::Fire), 5 => Some(Cell::Wood), + 6 => Some(Cell::Steam), _ => None, } } @@ -34,7 +36,7 @@ impl Cell { Cell::Empty => 0, Cell::Water => 1, Cell::Sand => 2, - Cell::Fire => 0, + Cell::Fire | Cell::Steam => 0, Cell::Stone | Cell::Wood => 255, // immovable } } @@ -42,6 +44,11 @@ impl Cell { /// Ticks a painted fire cell lives before it burns out. const FIRE_LIFETIME: u8 = 48; +const FIRE_HEAT: u8 = 200; +const WOOD_IGNITE: u8 = 80; +const WATER_BOIL: u8 = 100; +const STEAM_CONDENSE: u8 = 40; +const STEAM_PAINT_HEAT: u8 = 120; const CHUNK: usize = 16; pub struct World { @@ -50,6 +57,8 @@ pub struct World { cells: Vec, /// Per-cell remaining lifetime. Only `Fire` uses nonzero values. ttl: Vec, + /// Per-cell heat for conduction and phase changes. + heat: Vec, /// Scratch buffer marking cells that already moved this tick. moved: Vec, /// Tiny xorshift PRNG — no external crates needed in WASM. @@ -74,6 +83,7 @@ impl World { height, cells: vec![Cell::Empty; width * height], ttl: vec![0; width * height], + heat: vec![0; width * height], moved: vec![false; width * height], rng: 0x853c_49e6_748f_ea9b, frame: 0, @@ -104,6 +114,11 @@ impl World { &self.ttl } + #[inline] + pub fn heat(&self) -> &[u8] { + &self.heat + } + fn next_rand(&mut self) -> u64 { let mut x = self.rng; x ^= x << 13; @@ -138,6 +153,11 @@ impl World { let i = self.idx(px, py); self.cells[i] = cell; self.ttl[i] = if cell == Cell::Fire { FIRE_LIFETIME } else { 0 }; + self.heat[i] = match cell { + Cell::Fire => FIRE_HEAT, + Cell::Steam => STEAM_PAINT_HEAT, + _ => 0, + }; self.wake(px, py, false); } } @@ -148,6 +168,7 @@ impl World { let b = self.idx(x2, y2); self.cells.swap(a, b); self.ttl.swap(a, b); + self.heat.swap(a, b); self.moved[b] = true; self.wake(x1, y1, true); self.wake(x2, y2, true); @@ -270,6 +291,7 @@ impl World { let i = self.idx(x, y); self.cells[i] = Cell::Fire; self.ttl[i] = FIRE_LIFETIME; + self.heat[i] = FIRE_HEAT; self.moved[i] = true; self.wake(x, y, true); } @@ -319,6 +341,7 @@ impl World { let i = self.idx(x, y); self.cells[i] = Cell::Empty; self.ttl[i] = 0; + self.heat[i] = 0; self.wake(x, y, true); } @@ -399,6 +422,11 @@ impl World { } } + #[cfg(test)] + pub(crate) fn swap_for_test(&mut self, x1: usize, y1: usize, x2: usize, y2: usize) { + self.swap(x1, y1, x2, y2); + } + fn step_water(&mut self, x: usize, y: usize) { let below = y + 1; if below < self.height && self.try_move(x, y, x, below) { @@ -658,6 +686,39 @@ mod tests { assert_eq!(w.get(32, 63), Cell::Sand); } + #[test] + fn painted_steam_has_heat_120() { + let mut w = World::new(4, 4); + w.paint(1, 1, Cell::Steam, 0); + assert_eq!(w.get(1, 1), Cell::Steam); + assert_eq!(w.heat()[1 * 4 + 1], 120); + } + + #[test] + fn empty_cells_have_zero_heat() { + let w = World::new(2, 2); + assert!(w.heat().iter().all(|&h| h == 0)); + assert!(w.ttl().iter().all(|&t| t == 0)); + } + + #[test] + fn swap_moves_heat_with_the_cell() { + let mut w = World::new(4, 4); + w.paint(0, 0, Cell::Steam, 0); + assert_eq!(w.heat()[0], 120); + w.swap_for_test(0, 0, 1, 0); + assert_eq!(w.get(1, 0), Cell::Steam); + assert_eq!(w.heat()[1], 120); + assert_eq!(w.get(0, 0), Cell::Empty); + assert_eq!(w.heat()[0], 0); + } + + #[test] + fn from_u8_maps_steam() { + assert_eq!(Cell::from_u8(6), Some(Cell::Steam)); + assert_eq!(Cell::from_u8(7), None); + } + #[test] #[ignore] fn bench_960x600_step_rate() { From 0a000241fb2db68bbf40c9e8d678ec653e16b182 Mon Sep 17 00:00:00 2001 From: chowjiaming Date: Thu, 13 Aug 2026 17:22:26 -0400 Subject: [PATCH 03/10] fix: expect dead_code on heat API unused until later tasks Keep specified constants and World::heat without failing clippy -D warnings on this plumbing commit. --- src/world.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/world.rs b/src/world.rs index b9e7c3b..18e9ab2 100644 --- a/src/world.rs +++ b/src/world.rs @@ -45,8 +45,11 @@ impl Cell { /// Ticks a painted fire cell lives before it burns out. const FIRE_LIFETIME: u8 = 48; const FIRE_HEAT: u8 = 200; +#[expect(dead_code)] const WOOD_IGNITE: u8 = 80; +#[expect(dead_code)] const WATER_BOIL: u8 = 100; +#[expect(dead_code)] const STEAM_CONDENSE: u8 = 40; const STEAM_PAINT_HEAT: u8 = 120; const CHUNK: usize = 16; @@ -115,6 +118,7 @@ impl World { } #[inline] + #[expect(dead_code)] pub fn heat(&self) -> &[u8] { &self.heat } From 8f4947ffc9713fe19c59a08f18fe2074e02349e9 Mon Sep 17 00:00:00 2001 From: chowjiaming Date: Thu, 13 Aug 2026 17:24:33 -0400 Subject: [PATCH 04/10] feat: ignite wood from conducted heat Replace the p=0.1 adjacency roll with a threshold on the heat plane so fire is a temperature source, not a coin flip. --- src/world.rs | 68 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 59 insertions(+), 9 deletions(-) diff --git a/src/world.rs b/src/world.rs index 18e9ab2..21ba405 100644 --- a/src/world.rs +++ b/src/world.rs @@ -45,9 +45,7 @@ impl Cell { /// Ticks a painted fire cell lives before it burns out. const FIRE_LIFETIME: u8 = 48; const FIRE_HEAT: u8 = 200; -#[expect(dead_code)] const WOOD_IGNITE: u8 = 80; -#[expect(dead_code)] const WATER_BOIL: u8 = 100; #[expect(dead_code)] const STEAM_CONDENSE: u8 = 40; @@ -118,7 +116,7 @@ impl World { } #[inline] - #[expect(dead_code)] + #[cfg_attr(not(test), expect(dead_code))] pub fn heat(&self) -> &[u8] { &self.heat } @@ -300,13 +298,36 @@ impl World { self.wake(x, y, true); } - fn has_adjacent_wood(&self, x: usize, y: usize) -> bool { - self.for_each_cardinal(x, y, |cell| cell == Cell::Wood) + #[inline] + fn add_heat(&mut self, x: usize, y: usize, amount: u8) { + if x >= self.width || y >= self.height { + return; + } + let i = self.idx(x, y); + if self.cells[i] == Cell::Empty { + return; + } + self.heat[i] = self.heat[i].saturating_add(amount); + self.wake(x, y, true); + match self.cells[i] { + Cell::Wood if self.heat[i] >= WOOD_IGNITE => self.ignite(x, y), + Cell::Water if self.heat[i] >= WATER_BOIL => { + self.cells[i] = Cell::Steam; + self.ttl[i] = 0; + } + _ => {} + } } - fn try_ignite_neighbors(&mut self, x: usize, y: usize) { + #[inline] + fn conduct(&mut self, x: usize, y: usize) { const DIRS: [(isize, isize); 4] = [(0, -1), (0, 1), (-1, 0), (1, 0)]; + let i = self.idx(x, y); for (dx, dy) in DIRS { + let src = self.heat[i]; + if src == 0 { + return; + } let nx = x as isize + dx; let ny = y as isize + dy; if nx < 0 || ny < 0 { @@ -316,12 +337,24 @@ impl World { if nx >= self.width || ny >= self.height { continue; } - if self.get(nx, ny) == Cell::Wood && self.next_rand().is_multiple_of(10) { - self.ignite(nx, ny); + if self.get(nx, ny) == Cell::Empty { + continue; + } + let ni = self.idx(nx, ny); + let dst = self.heat[ni]; + if src <= dst { + continue; } + let give = ((src - dst) / 4).max(1); + self.heat[i] = self.heat[i].saturating_sub(give); + self.add_heat(nx, ny, give); } } + fn has_adjacent_wood(&self, x: usize, y: usize) -> bool { + self.for_each_cardinal(x, y, |cell| cell == Cell::Wood) + } + fn for_each_cardinal(&self, x: usize, y: usize, mut pred: impl FnMut(Cell) -> bool) -> bool { const DIRS: [(isize, isize); 4] = [(0, -1), (0, 1), (-1, 0), (1, 0)]; for (dx, dy) in DIRS { @@ -351,8 +384,9 @@ impl World { fn step_fire(&mut self, x: usize, y: usize) { self.wake(x, y, true); - self.try_ignite_neighbors(x, y); let i = self.idx(x, y); + self.heat[i] = FIRE_HEAT; + self.conduct(x, y); let life = self.ttl[i]; if life <= 1 { self.extinguish(x, y); @@ -663,6 +697,22 @@ mod tests { assert_eq!(w.get(4, 2), Cell::Wood); } + #[test] + fn wood_ignites_when_heat_reaches_threshold() { + let mut w = World::new(8, 8); + w.paint(4, 4, Cell::Wood, 0); + w.paint(3, 4, Cell::Fire, 0); + for _ in 0..8 { + w.step(); + } + assert_ne!( + w.get(4, 4), + Cell::Wood, + "wood did not ignite from conducted heat" + ); + assert_eq!(count(&w, Cell::Wood), 0); + } + #[test] fn wood_block_burns_within_n_ticks() { let mut w = World::new(8, 8); From a15a108a1e349157e2cb5ad2812c2f7471dcf703 Mon Sep 17 00:00:00 2001 From: chowjiaming Date: Thu, 13 Aug 2026 17:27:22 -0400 Subject: [PATCH 05/10] feat: boil water on heat and quench fire on contact Fire cannot remain a 200 source on a puddle; contact dumps heat into water so steam has a source besides painted gas. --- src/world.rs | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/world.rs b/src/world.rs index 21ba405..4bcb35d 100644 --- a/src/world.rs +++ b/src/world.rs @@ -374,6 +374,25 @@ impl World { false } + fn first_cardinal(&self, x: usize, y: usize, want: Cell) -> Option<(usize, usize)> { + const DIRS: [(isize, isize); 4] = [(0, -1), (0, 1), (-1, 0), (1, 0)]; + for (dx, dy) in DIRS { + let nx = x as isize + dx; + let ny = y as isize + dy; + if nx < 0 || ny < 0 { + continue; + } + let (nx, ny) = (nx as usize, ny as usize); + if nx >= self.width || ny >= self.height { + continue; + } + if self.get(nx, ny) == want { + return Some((nx, ny)); + } + } + None + } + fn extinguish(&mut self, x: usize, y: usize) { let i = self.idx(x, y); self.cells[i] = Cell::Empty; @@ -384,6 +403,13 @@ impl World { fn step_fire(&mut self, x: usize, y: usize) { self.wake(x, y, true); + if let Some((wx, wy)) = self.first_cardinal(x, y, Cell::Water) { + let wi = self.idx(wx, wy); + let bump = WATER_BOIL.saturating_sub(self.heat[wi]); + self.add_heat(wx, wy, bump); + self.extinguish(x, y); + return; + } let i = self.idx(x, y); self.heat[i] = FIRE_HEAT; self.conduct(x, y); @@ -460,12 +486,30 @@ impl World { } } + #[cfg(test)] + pub(crate) fn add_heat_for_test(&mut self, x: usize, y: usize, amount: u8) { + self.add_heat(x, y, amount); + } + #[cfg(test)] pub(crate) fn swap_for_test(&mut self, x1: usize, y1: usize, x2: usize, y2: usize) { self.swap(x1, y1, x2, y2); } fn step_water(&mut self, x: usize, y: usize) { + let i = self.idx(x, y); + if self.heat[i] > 0 { + self.conduct(x, y); + if self.get(x, y) != Cell::Water { + return; + } + } + if let Some((fx, fy)) = self.first_cardinal(x, y, Cell::Fire) { + let bump = WATER_BOIL.saturating_sub(self.heat[i]); + self.add_heat(x, y, bump); + self.extinguish(fx, fy); + return; + } let below = y + 1; if below < self.height && self.try_move(x, y, x, below) { return; @@ -740,6 +784,31 @@ mod tests { assert_eq!(w.get(32, 63), Cell::Sand); } + #[test] + fn water_at_boil_becomes_steam() { + let mut w = World::new(4, 4); + w.paint(1, 1, Cell::Water, 0); + w.add_heat_for_test(1, 1, WATER_BOIL); + assert_eq!(w.get(1, 1), Cell::Steam); + assert!(w.heat()[1 * 4 + 1] >= WATER_BOIL); + } + + #[test] + fn fire_touching_water_extinguishes_and_boils() { + let mut w = World::new(8, 8); + w.paint(4, 4, Cell::Water, 0); + w.paint(3, 4, Cell::Fire, 0); + w.step(); + assert_eq!(count(&w, Cell::Fire), 0, "fire survived contact with water"); + assert_eq!(count(&w, Cell::Steam), 1); + let steam_i = w + .cells() + .iter() + .position(|&c| c == Cell::Steam) + .expect("steam"); + assert!(w.heat()[steam_i] >= WATER_BOIL); + } + #[test] fn painted_steam_has_heat_120() { let mut w = World::new(4, 4); From e2c315507f50fe36a99107bc65996b8d9e4a8ae7 Mon Sep 17 00:00:00 2001 From: chowjiaming Date: Thu, 13 Aug 2026 17:29:45 -0400 Subject: [PATCH 06/10] feat: make steam rise, cool, and condense Give the heat plane a gas that is not fire: inverted gravity plus hysteresis below boil so cells do not flicker water/steam. --- src/world.rs | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/src/world.rs b/src/world.rs index 4bcb35d..dd0e2c0 100644 --- a/src/world.rs +++ b/src/world.rs @@ -47,7 +47,6 @@ const FIRE_LIFETIME: u8 = 48; const FIRE_HEAT: u8 = 200; const WOOD_IGNITE: u8 = 80; const WATER_BOIL: u8 = 100; -#[expect(dead_code)] const STEAM_CONDENSE: u8 = 40; const STEAM_PAINT_HEAT: u8 = 120; const CHUNK: usize = 16; @@ -279,6 +278,7 @@ impl World { Cell::Sand => self.step_sand(x, y), Cell::Water => self.step_water(x, y), Cell::Fire => self.step_fire(x, y), + Cell::Steam => self.step_steam(x, y), _ => {} } } @@ -449,6 +449,42 @@ impl World { } } + fn step_steam(&mut self, x: usize, y: usize) { + self.wake(x, y, true); + let i = self.idx(x, y); + self.heat[i] = self.heat[i].saturating_sub(1); + self.conduct(x, y); + let i = self.idx(x, y); + if self.cells[i] != Cell::Steam { + return; + } + if self.heat[i] < STEAM_CONDENSE { + self.cells[i] = Cell::Water; + self.ttl[i] = 0; + self.wake(x, y, true); + return; + } + if y == 0 { + return; + } + let above = y - 1; + if self.try_move(x, y, x, above) { + return; + } + let left_first = self.next_rand() & 1 == 0; + let diagonals: [(isize, usize); 2] = [(-1, above), (1, above)]; + for k in 0..2 { + let (dx, ny) = diagonals[if left_first { k } else { 1 - k }]; + let nx = x as isize + dx; + if nx < 0 { + continue; + } + if self.try_move(x, y, nx as usize, ny) { + return; + } + } + } + fn try_move(&mut self, x: usize, y: usize, nx: usize, ny: usize) -> bool { let mover = self.get(x, y); if nx >= self.width || ny >= self.height { @@ -842,6 +878,35 @@ mod tests { assert_eq!(Cell::from_u8(7), None); } + #[test] + fn steam_rises() { + let mut w = World::new(8, 8); + w.paint(4, 5, Cell::Steam, 0); + w.step(); + assert_eq!(w.get(4, 4), Cell::Steam); + assert_eq!(w.get(4, 5), Cell::Empty); + assert_eq!(w.heat()[4 * 8 + 4], 119); + assert_eq!(w.heat()[4 * 8 + 5], 0); + } + + #[test] + fn steam_condenses_when_heat_drops() { + let mut w = World::new(4, 4); + w.paint(1, 1, Cell::Steam, 0); + // 120 -> 39 takes 81 cools; isolated steam only loses 1/tick. + for _ in 0..81 { + w.step(); + } + assert_eq!(count(&w, Cell::Steam), 0); + assert_eq!(count(&w, Cell::Water), 1); + let wi = w + .cells() + .iter() + .position(|&c| c == Cell::Water) + .expect("water"); + assert!(w.heat()[wi] < STEAM_CONDENSE); + } + #[test] #[ignore] fn bench_960x600_step_rate() { From 7b99938d7f62966129d0c428e81e5c72cd6d71e8 Mon Sep 17 00:00:00 2001 From: chowjiaming Date: Thu, 13 Aug 2026 17:32:20 -0400 Subject: [PATCH 07/10] test: keep heating wood awake across idle chunks Heat writes must wake neighbors so ignition still works when only one fire/wood pair is active on a large grid. --- src/world.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/world.rs b/src/world.rs index dd0e2c0..3799fa2 100644 --- a/src/world.rs +++ b/src/world.rs @@ -820,6 +820,23 @@ mod tests { assert_eq!(w.get(32, 63), Cell::Sand); } + #[test] + fn heating_wood_stays_active_across_chunks() { + let mut w = World::new(64, 64); + w.paint(40, 40, Cell::Wood, 0); + w.paint(39, 40, Cell::Fire, 0); + w.step(); + let hi = 40 * 64 + 40; + assert!( + w.heat()[hi] > 0 || w.get(40, 40) == Cell::Fire, + "wood in a sparse world did not receive heat (chunk slept)" + ); + for _ in 0..8 { + w.step(); + } + assert_ne!(w.get(40, 40), Cell::Wood); + } + #[test] fn water_at_boil_becomes_steam() { let mut w = World::new(4, 4); From f28e1e73096451030099e5452b1d89db7fcb2a4a Mon Sep 17 00:00:00 2001 From: chowjiaming Date: Thu, 13 Aug 2026 17:34:23 -0400 Subject: [PATCH 08/10] feat: tint occupied cells by heat Make the new temperature plane visible without a debug overlay or a second renderer pass. --- src/renderer.rs | 59 ++++++++++++++++++++++++++++++++++++++++++++----- src/world.rs | 1 - 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/src/renderer.rs b/src/renderer.rs index 904c2d6..2c81153 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -19,23 +19,44 @@ fn put(frame: &mut [u8], i: usize, r: i16, g: i16, b: i16) { frame[o + 3] = 255; } +fn warm(r: i16, g: i16, b: i16, heat: u8) -> (i16, i16, i16) { + let t = heat as i16; + (r + t / 2, g, b.saturating_sub(t / 3)) +} + /// Write the whole world into `frame` (must be width*height*4 bytes). pub fn draw(world: &World, frame: &mut [u8]) { for (i, cell) in world.cells().iter().enumerate() { let x = i % world.width; let y = i / world.width; let j = jitter(x, y); + let h = world.heat()[i]; match cell { Cell::Empty => put(frame, i, 12, 12, 18), - Cell::Sand => put(frame, i, 194 + j, 168 + j, 96 + j / 2), - Cell::Water => put(frame, i, 40, 90 + j, 200 + j), - Cell::Stone => put(frame, i, 110 + j / 2, 110 + j / 2, 118), + Cell::Sand => { + let (r, g, b) = warm(194 + j, 168 + j, 96 + j / 2, h); + put(frame, i, r, g, b); + } + Cell::Water => { + let (r, g, b) = warm(40, 90 + j, 200 + j, h); + put(frame, i, r, g, b); + } + Cell::Stone => { + let (r, g, b) = warm(110 + j / 2, 110 + j / 2, 118, h); + put(frame, i, r, g, b); + } Cell::Fire => { let t = world.ttl()[i] as i16; put(frame, i, 255, 32 + t * 4, 16); } - Cell::Wood => put(frame, i, 118 + j / 2, 72 + j / 2, 36), - Cell::Steam => put(frame, i, 216 + j / 2, 220 + j / 2, 232), + Cell::Wood => { + let (r, g, b) = warm(118 + j / 2, 72 + j / 2, 36, h); + put(frame, i, r, g, b); + } + Cell::Steam => { + let (r, g, b) = warm(216 + j / 2, 220 + j / 2, 232, h); + put(frame, i, r, g, b); + } } } } @@ -45,6 +66,34 @@ mod tests { use super::*; use crate::world::{Cell, World}; + #[test] + fn steam_pixels_are_pale() { + let mut world = World::new(1, 1); + world.paint(0, 0, Cell::Steam, 0); + let mut frame = vec![0u8; 4]; + draw(&world, &mut frame); + let (r, g, b) = (frame[0], frame[1], frame[2]); + assert!(r > 180 && g > 180 && b > 180, "expected pale steam, got {r},{g},{b}"); + assert_eq!(frame[3], 255); + } + + #[test] + fn hot_wood_is_warmer_than_cold_wood() { + let mut cold = World::new(1, 1); + cold.paint(0, 0, Cell::Wood, 0); + let mut hot = World::new(1, 1); + hot.paint(0, 0, Cell::Wood, 0); + hot.add_heat_for_test(0, 0, 70); + let mut fc = vec![0u8; 4]; + let mut fh = vec![0u8; 4]; + draw(&cold, &mut fc); + draw(&hot, &mut fh); + assert!( + fh[0] > fc[0] || fh[2] < fc[2], + "hot wood should be redder or less blue: cold={fc:?} hot={fh:?}" + ); + } + #[test] fn fire_pixels_are_orange_red() { let mut world = World::new(2, 1); diff --git a/src/world.rs b/src/world.rs index 3799fa2..437a3d2 100644 --- a/src/world.rs +++ b/src/world.rs @@ -115,7 +115,6 @@ impl World { } #[inline] - #[cfg_attr(not(test), expect(dead_code))] pub fn heat(&self) -> &[u8] { &self.heat } From 0127859bd64752767624af86d3551466163bda32 Mon Sep 17 00:00:00 2001 From: chowjiaming Date: Thu, 13 Aug 2026 17:37:09 -0400 Subject: [PATCH 09/10] feat: add steam to the palette and document heat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose Cell::Steam as material 6 in the UI so boiling is paintable and the README matches fire/wood’s heat rules. --- README.md | 7 ++++--- index.html | 3 +++ src/lib.rs | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 08dae89..a2dbd23 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,10 @@ unit-testable with plain `cargo test`. | Sand | Falls and piles; sinks through water | | Water | Flows and spreads | | Stone | Static obstacle | -| Fire | Rises, flickers, burns out | -| Wood | Static; ignites from adjacent fire | -| Erase / Clear | Remove cells | +| Fire | Rises, flickers, burns out; heats neighbors | +| Wood | Static; ignites when heat reaches 80 | +| Steam | Rises; condenses when cool; water boils next to fire | +| Erase / Clear | Remove cells | ## Quickstart diff --git a/index.html b/index.html index 6a71999..d4ee3ef 100644 --- a/index.html +++ b/index.html @@ -101,6 +101,9 @@

Sandboxed

+ diff --git a/src/lib.rs b/src/lib.rs index cf3f29e..9794952 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -49,7 +49,7 @@ impl Simulation { self.world.height } - /// Paint `material` (0=erase, 1=sand, 2=water, 3=stone, 4=fire, 5=wood) at (x, y). + /// Paint `material` (0=erase, 1=sand, 2=water, 3=stone, 4=fire, 5=wood, 6=steam) at (x, y). pub fn paint(&mut self, x: usize, y: usize, material: u8, radius: usize) { if let Some(cell) = Cell::from_u8(material) { self.world.paint(x, y, cell, radius); From c554580e3bbc1b920565fa4068dd5a0b514030ee Mon Sep 17 00:00:00 2001 From: chowjiaming Date: Thu, 13 Aug 2026 17:37:46 -0400 Subject: [PATCH 10/10] style: rustfmt renderer after heat tint Keep cargo fmt --check green in just verify / CI. --- src/renderer.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/renderer.rs b/src/renderer.rs index 2c81153..50d8211 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -73,7 +73,10 @@ mod tests { let mut frame = vec![0u8; 4]; draw(&world, &mut frame); let (r, g, b) = (frame[0], frame[1], frame[2]); - assert!(r > 180 && g > 180 && b > 180, "expected pale steam, got {r},{g},{b}"); + assert!( + r > 180 && g > 180 && b > 180, + "expected pale steam, got {r},{g},{b}" + ); assert_eq!(frame[3], 255); }