Tile-based Simulation in Machi

3 min read

Bootstrapped the tile map logic in Machi. The architecture is dead simple.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub enum TileType {
Air,
Dirt,
Stone,
Water,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Tile {
pub tile_type: TileType,
pub water_amount: u16, // 0 = dry, 1024 = full
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TileMap {
pub width: usize,
pub height: usize,
pub tiles: Vec<Tile>,
}
impl TileMap {
pub fn new(width: usize, height: usize) -> Self {
let tiles = vec![Tile {
tile_type: TileType::Air,
water_amount: 0,
}; width * height];
TileMap { width, height, tiles }
}
pub fn get_tile(&self, x: usize, y: usize) -> Option<&Tile> {
if x < self.width && y < self.height {
Some(&self.tiles[y * self.width + x])
} else {
None
}
}
pub fn set_tile(&mut self, x: usize, y: usize, tile: Tile) {
if x < self.width && y < self.height {
self.tiles[y * self.width + x] = tile;
}
}
}
There is TileType which are of Ari, Dirt, Stone or Water.
Then there is water_amount
which specify the amount of water in water tile.
Here is full water simulation code from Claude 4.0.
pub fn simulate_water(&mut self) {
let w = self.tile_map.width;
let h = self.tile_map.height;
let len = w * h;
// Signed changes for each tile (outflow = negative, inflow = positive)
let mut delta: Vec<i32> = vec![0; len];
// --- 1 ░ Gather phase -------------------------------------------------
for y in 0..h {
for x in 0..w {
let i = y * w + x;
let tile = &self.tile_map.tiles[i];
// Only flowing water can move
if tile.tile_type != TileType::Water || tile.water_amount == 0 {
continue;
}
let mut remaining = tile.water_amount;
// helper to register a flow
let mut push = |from_idx: usize, to_idx: usize, amount: u16| {
if amount == 0 { return; }
delta[from_idx] -= amount as i32;
delta[to_idx] += amount as i32;
};
// ── a) Vertical – gravity first (toward smaller world-y)
if y > 0 {
let j = (y - 1) * w + x;
let below = &self.tile_map.tiles[j];
if below.tile_type == TileType::Air ||
(below.tile_type == TileType::Water &&
below.water_amount < MAX_WATER_AMOUNT)
{
let room = MAX_WATER_AMOUNT - below.water_amount;
let flow = remaining.min(room);
remaining -= flow;
push(i, j, flow);
}
}
// ── b) Horizontal – equalise with neighbours
// Only move half the height difference to avoid “teleporting”
let neighbours = [
(x.wrapping_sub(1), y), // left (wraps harmlessly for x=0)
(x + 1, y), // right
];
for (nx, ny) in neighbours {
if nx >= w { continue; }
let j = ny * w + nx;
let n_tile = &self.tile_map.tiles[j];
if n_tile.tile_type == TileType::Stone || n_tile.tile_type == TileType::Dirt {
continue; // solid wall
}
let target = (remaining as i32 + n_tile.water_amount as i32) / 2;
if remaining as i32 > target {
let flow = (remaining as i32 - target) as u16;
remaining -= flow;
push(i, j, flow);
}
}
// ── c) Optional small upflow (pressure equalisation) -------------
// Not strictly needed – comment out if you want one-way gravity.
}
}
// --- 2 ░ Apply phase ---------------------------------------------------
for idx in 0..len {
let change = delta[idx];
if change == 0 { continue; }
let t = &mut self.tile_map.tiles[idx];
let new_amt = (t.water_amount as i32 + change)
.clamp(0, MAX_WATER_AMOUNT as i32) as u16;
// Flip tile_type depending on new water level
if new_amt == 0 {
if t.tile_type == TileType::Water {
t.tile_type = TileType::Air;
}
} else {
t.tile_type = TileType::Water;
}
t.water_amount = new_amt;
}
}
It’s good enough for my use-case. Had to iterate a few times with the agent because the processing order was impacting the water simulation and had directional bias.
0
Subscribe to my newsletter
Read articles from Sprited Dev directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
