Tile-based Simulation in Machi

Sprited DevSprited Dev
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

Sprited Dev
Sprited Dev