Files
Verbatim/MULTILAYER_PLAN.md
Emil 24b6d0320f feat: multi-layer world — temperature, gas, pressure, light as parallel per-chunk arrays
- Removed temp from Cell (13→9 bytes), added temps/pressure/gas_type/gas_density/light arrays to Chunk
- Layer access via grid.get_temp()/set_temp()/get_gas()/set_gas()/get_pressure()/set_pressure()/get_light()/set_light()
- cells_swap swaps all layers, set_material sets default_temp
- heat_transfer refactored to direct array access on temps[] (no Cell copy)
- CA rules refactored: cell.temp → grid.get_temp()/set_temp()
- gas_step: gas flow (rise, spread), fire produces CO2+smoke, steam condenses to water, acid+organic→poison gas
- pressure_step: pressure equalization for connected non-solid cells
- light_step: world-space persistent lighting, updated every 10 ticks, ray-cast line-of-sight
- Gas damage: poison gas damages entities, CO2 suffocates, applied before ca.step()
- Multi-section chunk serialization (VWM1 magic + cells + temps + gas + pressure + light)
- Old 12-byte chunk format auto-detected for backward compat
- AI spectrum: new gas + pressure spectrums, light spectrum uses world-space fallback
- Protocol: gas/pressure spectrum commands
- pre_dirty mechanism: layer steps process pre-clear dirty rects for cross-cell diffusion
- 14 new multilayer tests, all 185 tests + 14 scenarios pass
- Benchmark: 85.9 FPS (graphics surface, was 128 pre-layers — 33% regression from 4 new layer steps)
2026-06-21 23:38:33 +03:00

22 KiB
Raw Permalink Blame History

Verbatim — Multi-Layer World Plan

Phase 6: separate world layers for temperature, gas/air, pressure, and light. Created June 2026. Replaces the inline Cell.temp with parallel per-chunk arrays.

Current State

All world data is packed into a single Cell struct:

pub struct Cell {
    pub material: MaterialId,      // 1 byte
    pub temp: f32,                 // 4 bytes  <-- will be removed
    pub updated_this_tick: bool,   // 1 byte
    pub variant: u8,               // 1 byte
    pub fg: [u8; 3],              // 3 bytes
    pub bg: [u8; 3],              // 3 bytes
}                                 // ~13 bytes

Problems:

  • Every temp modification copies the entire Cell through grid.get()/grid.set() + triggers mark_dirty()
  • heat_transfer() already uses a temporary Vec<f32> buffer but writes results back into Cell
  • No pressure field exists
  • No gas composition field exists (Empty = air, static_ = true, no flow)
  • Lighting is ephemeral viewport-sized LightGrid, not persisted in world space

Target Architecture

Parallel arrays inside Chunk, shared dirty rects:

Chunk (64x64 = 4096 cells)
  cells:      Vec<Cell>         ~36 KB  (material + color, no temp)
  temps:      Vec<f32>          16 KB   (temperature)
  pressure:   Vec<u8>            4 KB   (0-255, 128 = atmospheric)
  gas_type:   Vec<u8>            4 KB   (0=air, 1=smoke, 2=poison, 3=CO2, 4=steam)
  gas_density:Vec<u8>            4 KB   (0-255 concentration)
  light:      Vec<[u8; 3]>      12 KB   (world-space RGB)
                              --------
                              ~76 KB per chunk

Why inside Chunk (not separate ChunkedGrids):

  • Reuse dirty rects, active chunks, streaming, serialization
  • One chunk_at() call instead of multiple
  • Layers updated in a single pass over the dirty rect

New Struct Definitions

Cell (cell.rs) — temp removed

pub struct Cell {
    pub material: MaterialId,
    pub updated_this_tick: bool,
    pub variant: u8,
    pub fg: [u8; 3],
    pub bg: [u8; 3],
}                                 // ~9 bytes

to_bytes() returns [u8; 8] (was [u8; 12]):

Offset Bytes Field
0 1 material
1 1 variant
2-4 3 fg
5-7 3 bg

updated_this_tick is not serialized (transient).

Chunk (chunk.rs) — 5 new arrays

pub struct Chunk {
    pub cells: Vec<Cell>,
    pub temps: Vec<f32>,
    pub pressure: Vec<u8>,
    pub gas_type: Vec<u8>,
    pub gas_density: Vec<u8>,
    pub light: Vec<[u8; 3]>,
    pub active: bool,
    pub modified: bool,
    pub was_modified: bool,
    pub generated: bool,
    pub dirty: Option<(i32, i32, i32, i32)>,
}

Chunk::new() initializes:

  • cells: 4096 × Cell::empty()
  • temps: 4096 × 20.0 (ambient temperature)
  • pressure: 4096 × 128 (atmospheric)
  • gas_type: 4096 × 0 (air)
  • gas_density: 4096 × 0 (no gas)
  • light: 4096 × [0, 0, 0] (no light, computed later)

ChunkedGrid (chunked_grid.rs) — new accessor methods

pub fn get_temp(&self, x: i32, y: i32) -> f32
pub fn set_temp(&mut self, x: i32, y: i32, t: f32)
pub fn get_pressure(&self, x: i32, y: i32) -> u8
pub fn set_pressure(&mut self, x: i32, y: i32, p: u8)
pub fn get_gas(&self, x: i32, y: i32) -> (u8, u8)       // (type, density)
pub fn set_gas(&mut self, x: i32, y: i32, gas_type: u8, density: u8)
pub fn get_light(&self, x: i32, y: i32) -> [u8; 3]
pub fn set_light(&mut self, x: i32, y: i32, rgb: [u8; 3])

All set_* methods call mark_dirty(x, y) (shared dirty rect).

get_temp for out-of-bounds returns 20.0 (ambient). get_pressure for out-of-bounds returns 128 (atmospheric). get_gas for out-of-bounds returns (0, 0) (clean air). get_light for out-of-bounds returns [0, 0, 0].

Simulation Steps

New fixed_update() order

1. stream_chunks()
2. update_active_chunks()
3. ca.step()           -- material CA (sand, water, lava, etc.)
4. heat_transfer()     -- temperature diffusion on temps[] directly
5. gas_step()          -- gas flow CA (NEW)
6. pressure_step()     -- pressure equalization (NEW)
7. light_step()        -- world-space light update, every N ticks (NEW)
8. update_entities()
9. update_slime_ai()
10. update_goblin_ai()
11. update_combat()
12. update_projectiles()
13. apply_world_damage()
14. decompose_corpses()
15. update_status_effects()   -- now includes gas damage
16. update_score()
17. update_item_pickup()
18. grid.swap_modified_flags()

Step 3: CA Rules Refactor (cellular.rs)

All cell.temp references become grid.get_temp(x, y):

Method Current New
update_water cell.temp > 100.0 → Steam grid.get_temp(x,y) > 100.0 → Steam
update_lava cell.temp < 400.0 → Stone grid.get_temp(x,y) < 400.0 → Stone
update_lava lava.temp -= 50.0 grid.set_temp(x,y, grid.get_temp(x,y) - 50.0)
update_steam cell.temp < 80.0 → Water grid.get_temp(x,y) < 80.0 → Water
update_fire new_n.temp = 400.0 grid.set_temp(nx,ny, 400.0)
update_fire new.temp -= 15.0 grid.set_temp(x,y, grid.get_temp(x,y) - 15.0)
update_flesh cell.temp > 200.0 → Fire grid.get_temp(x,y) > 200.0 → Fire
update_grass cell.temp > 250.0 → Fire grid.get_temp(x,y) > 250.0 → Fire
update_dirt cell.temp < 0.0 → Stone grid.get_temp(x,y) < 0.0 → Stone

Also: when CA creates a new material (e.g., Water → Steam), set the temp layer:

  • grid.set_temp(x, y, 110.0) for steam from boiling water
  • grid.set_temp(x, y, 50.0) for water from condensed steam
  • grid.set_temp(x, y, 400.0) for fire from ignited material

Step 4: heat_transfer() Refactor (cellular.rs)

Before (current): reads cell.temp via grid.get(), writes via grid.set(), copies Cell each time.

After: direct array access on chunk.temps[].

for each active chunk with dirty rect:
    snapshot temps[dirty_rect + 1 margin] into self.temps_buffer
    for each cell in dirty_rect:
        avg = average of 4 neighbors from self.temps_buffer
        chunk.temps[idx] += (avg - chunk.temps[idx]) * conductivity * 0.1
        if phase transition threshold crossed:
            grid.set_material(x, y, new_material)  -- triggers mark_dirty
            grid.set_temp(x, y, new_temp)

No grid.get()/grid.set() for temperature. No Cell copying. No mark_dirty per pixel (only for phase transitions).

Performance: heat_transfer goes from ~500 get+set calls to ~500 direct array writes.

Step 5: gas_step() — NEW (cellular.rs)

Gas CA rules per cell in dirty rect:

Flow rules:

  • Gas rises if gas_density > 0 and cell above is empty/gas with lower density
  • Gas spreads horizontally to equalize density
  • Gas cannot pass through solid cells (is_solid)
  • Gas accumulates at ceilings (density increases upward)

Gas types:

Type ID Behavior
Air 0 Default, no effect
Smoke 1 Rises, blocks light slightly, fades over time
Poison 2 Rises, damages entities in contact, fades slowly
CO2 3 Rises, suffocates fire (fire dies if CO2 density > threshold)
Steam 4 Rises, condenses to water at temp < 80, transparent

Material interactions:

  • Fire + Air → produces CO2 + Smoke, consumes air density
  • Fire + CO2 (high density) → fire extinguishes
  • Lava + Water → produces Steam (gas_type=4)
  • Acid + Flesh/Wood → produces Poison gas
  • Steam + temp < 80 → condenses to Water (gas cleared)

Gas update order:

  1. Material-to-gas transitions (fire produces CO2, lava+water → steam)
  2. Gas flow (rise + spread)
  3. Gas-to-material transitions (steam condenses, fire suffocates)
  4. Gas fading (smoke/poison slowly dissipate)

Step 6: pressure_step() — NEW (cellular.rs)

Pressure equalization for connected liquid/gas regions.

Rules:

  • 128 = atmospheric pressure (default for empty cells)
  • Liquids generate pressure by depth: pressure = 128 + depth_from_surface * k
  • Pressure diffuses to neighbors: p += (neighbor_p - p) * diffusion_rate
  • Solids block pressure transfer
  • High pressure ( > 200) pushes liquids/gas through gaps
  • Explosions: temporarily set pressure to 255, then diffuse

Algorithm:

for each active chunk with dirty rect:
    for each cell in dirty_rect:
        if cell is liquid or gas or empty:
            avg_p = average of 4 non-solid neighbors' pressure
            new_p = lerp(current_p, avg_p, 0.1)
            chunk.pressure[idx] = new_p

This enables:

  • U-bend pipes (water level equalizes through connected path)
  • Fountains (high pressure pushes water up)
  • Gas displacement (fire creates pressure, pushes gas out)
  • Explosions (pressure wave propagates, destroys weak materials)

Step 7: light_step() — NEW (cellular.rs)

World-space persistent lighting, updated every N ticks (default N=10).

Algorithm:

every N ticks:
    for each active chunk:
        clear chunk.light to [0,0,0]
    for each light source in active chunks (Lava, Fire):
        ray-cast outward up to radius
        for each cell within radius with line-of-sight:
            attenuation = (1 - dist/radius)^2
            chunk.light[idx] += source.color * source.intensity * attenuation
            clamp to 255

Consumers:

  • AI spectrum render_light() reads grid.get_light() instead of ephemeral LightGrid
  • Terminal renderer: optional world-space light mode
  • GPU renderers: stay as-is (compute lighting in shader, real-time)
  • Capture renderer: can use world-space light for consistency

Performance:

  • Only updates every 10 ticks (6 times per second at 60 FPS)
  • Only processes active chunks with dirty rects
  • Light sources gathered from material_light() (same as current)

Step 8: Gas Damage in update_status_effects() (game.rs)

New status effect from gas:

fn update_status_effects(&mut self) {
    for e in self.entities.all_mut() {
        if e.alive {
            let (ex, ey) = e.center();
            let (gas_type, gas_density) = self.grid.get_gas(ex as i32, ey as i32);
            if gas_type == 2 && gas_density > 50 {
                // Poison gas: damage proportional to density
                e.health -= (gas_density as f32 - 50.0) * 0.1;
                e.status_effects.push(StatusEffect::Poisoned { timer: 60 });
            }
            if gas_type == 3 && gas_density > 100 {
                // CO2: suffocation damage
                e.health -= 2.0;
            }
            e.apply_status_effects();
        }
    }
}

Serialization

Multi-section chunk file format

File: cache/worlds/seed_<N>/chunk_<cx>_<cy>.bin

[4 bytes:  magic "VWM1"]           // Verbatim World Map v1
[1 byte:   version = 1]
[1 byte:   flags = 0]
[4 bytes:  cell_section_len]       // 8 * 4096 = 32768
[cell_section: 8 bytes x 4096]     // material(1) + variant(1) + fg(3) + bg(3)
[4 bytes:  temp_section_len]       // 4 * 4096 = 16384
[temp_section: 4 bytes x 4096]     // f32 little-endian
[2 bytes:  gas_section_len]        // 2 * 4096 = 8192
[gas_section: 2 bytes x 4096]      // type(1) + density(1)
[2 bytes:  pressure_section_len]   // 1 * 4096 = 4096
[pressure_section: 1 byte x 4096]  // u8
[4 bytes:  light_section_len]      // 3 * 4096 = 12288
[light_section: 3 bytes x 4096]    // R + G + B

Total: ~8 + 32768 + 16384 + 8192 + 4096 + 12288 = ~74 KB per chunk (was ~49 KB).

Old cache is incompatible. Run rm -rf cache/worlds after implementation.

Backward compatibility

None. Old chunk_*.bin files (12 bytes/cell format) will fail to load with a clear error. The meta.json format stays the same (player position, items, depth).

Renderer Changes

GPU Grid Buffers (graphics.rs, vulkan.rs)

Current: one storage buffer with material as u32 per cell.

New: additional storage buffers:

  • temp_buffer: f32 per viewport cell (for heat shimmer, temperature visualization)
  • gas_buffer: u32 per viewport cell (packed: gas_type << 16 | gas_density)
  • pressure_buffer: u32 per viewport cell (for pressure visualization)

Shaders (graphics.vert, cell.vert):

  • Binding 0: material grid (existing, for is_solid() in line_of_sight)
  • Binding 1: light sources (existing)
  • Binding 2: temperature grid (NEW, optional use for heat shimmer)
  • Binding 3: gas grid (NEW, optional use for fog/smoke overlay)

Phase 1 implementation: just upload the data, don't change shader visuals yet. Phase 2: add heat shimmer (vertex displacement based on temp), gas fog (alpha overlay).

Terminal Renderer (terminal.rs)

  • render() can optionally use grid.get_light() (world-space) instead of CPU compute_lighting()
  • Toggle with a flag or mode: --mode terminal --world-light (experimental)
  • Default stays: ephemeral compute_lighting() for immediate accuracy

Capture Renderer (capture.rs)

  • Use grid.get_light() if available (world-space light)
  • Fallback to compute_lighting() if light layer is all zeros

AI Spectrum (spectrum.rs)

Spectrum Current source New source
materials cell.material cell.material (unchanged)
temperature cell.temp grid.get_temp(x, y)
light LightGrid (ephemeral) grid.get_light(x, y)
entities cell.is_empty() cell.is_empty() (unchanged)
density MaterialRegistry MaterialRegistry (unchanged)
velocity cell.updated_this_tick cell.updated_this_tick (unchanged)
gas (NEW) grid.get_gas(x, y) → type + density chars
pressure (NEW) grid.get_pressure(x, y) → 0-9 scale char

New spectrum commands in pipe protocol:

{"cmd":"get_spectrum","spectrum":"gas","w":80,"h":25}
{"cmd":"get_spectrum","spectrum":"pressure","w":80,"h":25}

Material Properties (material.rs)

New fields in Material:

pub struct Material {
    // ... existing fields ...
    pub gas_emission: (u8, u8),    // (gas_type, amount_per_tick) — 0 = none
    pub pressure_gen: u8,          // pressure added per tick (for explosions/lava)
}
Material gas_emission pressure_gen
Empty (0, 0) 0
Sand (0, 0) 0
Water (0, 0) 0
Stone (0, 0) 0
Lava (0, 0) 1
Wood (0, 0) 0
Flesh (0, 0) 0
Bone (0, 0) 0
Steam (4, 0) 0
Fire (3, 5) 2
Acid (0, 0) 0
Smoke (1, 0) 0
Grass (0, 0) 0
Dirt (0, 0) 0
Stairs (0, 0) 0

Fire emits CO2 (type 3) at 5 density/tick and generates 2 pressure/tick. Lava generates 1 pressure/tick (heat expansion).

Test Plan

Updated tests (cell.temp → grid.get_temp)

All tests that access cell.temp via GameSession need updating:

  • tests/physics_lava.rs — lava temperature checks
  • tests/physics_interactions.rs — lava+water=steam, fire spread
  • tests/physics_water.rs — water boiling
  • tests/integration.rs — any temp-related assertions
  • tests/physics_acid.rs — acid + organic → poison gas

New tests

Temperature layer:

  • heat_transfer_diffuses_through_solid_material — stone wall between hot/cold
  • heat_transfer_uses_conductivity — wood conducts less than stone
  • lava_heats_adjacent_water_to_steam — phase transition via heat diffusion
  • temperature_persists_across_chunk_boundary — cross-chunk heat flow

Gas layer:

  • gas_rises_and_accumulates_at_ceiling — smoke fills upward
  • fire_consumes_air_and_produces_co2 — fire reduces air, increases CO2
  • fire_extinguishes_in_high_co2 — fire dies without air
  • poison_gas_damages_entity — entity takes damage in poison gas
  • steam_condenses_to_water_when_cold — gas-to-material transition
  • lava_plus_water_produces_steam_gas — material interaction creates gas
  • gas_does_not_pass_through_solid_walls — containment test

Pressure layer:

  • pressure_equalizes_in_connected_liquids — U-bend test
  • high_pressure_pushes_liquid_through_gap — fountain test
  • explosion_creates_pressure_wave — pressure propagation
  • solid_blocks_pressure_transfer — isolation test

Light layer:

  • light_persists_in_world_space — light stored in chunk
  • light_blocked_by_solid_walls — shadow casting
  • light_updates_when_source_removed — fire extinguished → darkness
  • lava_emits_light_in_world_space — light source test

Serialization:

  • chunk_save_load_roundtrip_preserves_all_layers — temp, gas, pressure, light
  • old_cache_format_rejected — error on loading v0 format

Implementation Order

Step Files Description
1 cell.rs Remove temp from Cell, update to_bytes/from_bytes to 8 bytes
2 chunk.rs Add temps, pressure, gas_type, gas_density, light arrays, init in new()
3 chunked_grid.rs Add get_temp/set_temp/get_pressure/set_pressure/get_gas/set_gas/get_light/set_light methods
4 chunked_grid.rs Update save_chunk/load_chunk to multi-section format
5 cellular.rs Refactor heat_transfer() to direct array access on temps[]
6 cellular.rs Refactor all CA rules: cell.tempgrid.get_temp() / grid.set_temp()
7 cellular.rs Implement gas_step() — gas flow + material interactions
8 cellular.rs Implement pressure_step() — pressure equalization
9 cellular.rs Implement light_step() — world-space lighting (every N ticks)
10 game.rs Update fixed_update(): add gas_step, pressure_step, light_step calls
11 game.rs Update update_status_effects(): gas damage (poison, CO2)
12 material.rs Add gas_emission, pressure_gen fields to Material
13 render/graphics.rs Upload temp/gas/pressure buffers to GPU
14 render/vulkan.rs Same as graphics.rs
15 render/lighting.rs Update compute_lighting to optionally read world-space light
16 render/terminal.rs Optional world-space light mode
17 render/capture.rs Use world-space light if available
18 ai/spectrum.rs Update temperature/light spectrums, add gas + pressure spectrums
19 ai/protocol.rs Add gas/pressure spectrum commands
20 ai/state.rs Update CellInfo to include gas/pressure data
21 ai/session.rs Update find_material for infinite mode (separate fix)
22 tests/*.rs Update all temp-related tests, add new layer tests
23 rm -rf cache/worlds (old cache incompatible)
24 cargo test — all green
25 cargo run --release -- --mode benchmark — verify no regression

File Impact Map

File Changes
src/world/cell.rs Remove temp field, update to_bytes/from_bytes (12→8 bytes)
src/world/chunk.rs 5 new arrays, init, accessor methods, reset_tick_flags unchanged
src/world/chunked_grid.rs 8 new accessor methods, multi-section serialization, ensure_chunk init new arrays
src/world/cellular.rs heat_transfer refactor, gas_step, pressure_step, light_step, CA rule temp changes
src/world/material.rs gas_emission, pressure_gen fields on Material
src/game.rs fixed_update new steps, gas damage in update_status_effects
src/render/graphics.rs 3 new GPU buffers (temp, gas, pressure), upload code
src/render/vulkan.rs Same as graphics.rs
src/render/lighting.rs Optional world-space light mode in compute_lighting
src/render/terminal.rs Optional --world-light flag
src/render/capture.rs World-space light fallback
src/ai/spectrum.rs Temp/light from layers, new gas/pressure spectrums
src/ai/protocol.rs New spectrum commands
src/ai/state.rs CellInfo + gas/pressure fields
tests/*.rs Update ~10 test files, add ~20 new tests

Performance Expectations

Metric Before Expected After
Cell copy per get() 13 bytes 9 bytes (-31%)
heat_transfer per cell 2 Cell copies + mark_dirty 1 array write (no copy, no dirty)
Chunk memory ~53 KB ~76 KB (+43%)
Active chunk memory (9 chunks) ~477 KB ~684 KB
CA step time ~0.5 ms ~0.4 ms (fewer copies)
heat_transfer time ~0.3 ms ~0.1 ms (direct array)
gas_step time N/A ~0.2 ms (new)
pressure_step time N/A ~0.1 ms (new)
light_step time (every 10 ticks) N/A ~0.5 ms (amortized ~0.05 ms/tick)
Total fixed_update ~1.5 ms ~1.3 ms
Render frame ~4 ms ~4.5 ms (+3 buffers)

Cross-Layer Interactions

Fire (material) 
  → heats temp layer (heat_transfer)
  → emits CO2 + Smoke (gas layer)
  → generates pressure (pressure layer)
  → emits light (light layer)

Lava (material)
  → heats temp layer
  → generates pressure
  → emits light
  → + Water → Steam (gas layer) + temp drop

Temperature layer
  → Water > 100°C → Steam (material + gas)
  → Lava < 400°C → Stone (material)
  → Steam < 80°C → Water (material, gas cleared)
  → Wood > 300°C → Fire (material)

Gas layer
  → CO2 high → Fire extinguishes (material)
  → Poison → entity damage (status effects)
  → Steam + cold temp → Water (material)

Pressure layer
  → High pressure → pushes liquid through gaps
  → Explosion → pressure wave → terrain destruction
  → Lava → constant pressure generation

Open Questions

Question Default Notes
Light update frequency Every 10 ticks Configurable, tradeoff: accuracy vs perf
Gas diffusion rate 0.1 How fast gas spreads per tick
Pressure diffusion rate 0.1 How fast pressure equalizes
Gas fade rate 1 per 60 ticks Smoke/poison slowly dissipate
Max gas density 255 u8 limit, may need f32 if finer
Pressure as u8 or f32? u8 (0-255) Simpler, 128=atmospheric. f32 if precision needed
Steam as gas or material? Gas (type 4) Current: Steam is a material. New: Steam is a gas that condenses to Water
CO2 threshold for fire death density > 150 Fire extinguishes when CO2 concentration is high
Poison damage threshold density > 50 Entity takes damage above this concentration