diff --git a/world/src/civ/mod.rs b/world/src/civ/mod.rs
index 9a34d83de0..96ff4c98f9 100644
--- a/world/src/civ/mod.rs
+++ b/world/src/civ/mod.rs
@@ -76,6 +76,10 @@ impl Civs {
         let rng = ChaChaRng::from_seed(seed_expan::rng_state(seed));
         let mut ctx = GenCtx { sim, rng };
 
+        for _ in 0..100 {
+            this.generate_cave(&mut ctx);
+        }
+
         for _ in 0..INITIAL_CIV_COUNT {
             debug!("Creating civilisation...");
             if this.birth_civ(&mut ctx.reseed()).is_none() {
@@ -208,6 +212,62 @@ impl Civs {
         this
     }
 
+    // TODO: Move this
+    fn generate_cave(&self, ctx: &mut GenCtx<impl Rng>) {
+        let mut pos = ctx.sim
+            .get_size()
+            .map(|sz| ctx.rng.gen_range(0, sz as i32) as f32);
+        let mut vel = pos
+            .map2(ctx.sim.get_size(), |pos, sz| sz as f32 / 2.0 - pos)
+            .try_normalized()
+            .unwrap_or_else(Vec2::unit_y);
+
+        let path = (-100..100)
+            .filter_map(|i: i32| {
+                let depth = (i.abs() as f32 / 100.0 * std::f32::consts::PI / 2.0).cos();
+                vel = (vel + Vec2::new(
+                    ctx.rng.gen_range(-0.25, 0.25),
+                    ctx.rng.gen_range(-0.25, 0.25),
+                ))
+                    .try_normalized()
+                    .unwrap_or_else(Vec2::unit_y);
+                let old_pos = pos.map(|e| e as i32);
+                pos = (pos + vel * 0.5).clamped(Vec2::zero(), ctx.sim.get_size().map(|e| e as f32 - 1.0));
+                Some((pos.map(|e| e as i32), depth)).filter(|(pos, _)| *pos != old_pos)
+            })
+            .collect::<Vec<_>>();
+
+        for locs in path.windows(3) {
+            let to_prev_idx = NEIGHBORS
+                .iter()
+                .enumerate()
+                .find(|(_, dir)| **dir == locs[0].0 - locs[1].0)
+                .expect("Track locations must be neighbors")
+                .0;
+            let to_next_idx = NEIGHBORS
+                .iter()
+                .enumerate()
+                .find(|(_, dir)| **dir == locs[2].0 - locs[1].0)
+                .expect("Track locations must be neighbors")
+                .0;
+
+            ctx.sim.get_mut(locs[0].0).unwrap().cave.0.neighbors |=
+                1 << ((to_prev_idx as u8 + 4) % 8);
+            ctx.sim.get_mut(locs[2].0).unwrap().cave.0.neighbors |=
+                1 << ((to_next_idx as u8 + 4) % 8);
+            let mut chunk = ctx.sim.get_mut(locs[1].0).unwrap();
+            chunk.cave.0.neighbors |=
+                (1 << (to_prev_idx as u8)) | (1 << (to_next_idx as u8));
+            let depth = locs[1].1 * 250.0;
+            chunk.cave.1.alt = chunk.alt - depth + ctx.rng.gen_range(-4.0, 4.0) * (depth > 10.0) as i32 as f32;
+            chunk.cave.1.width = ctx.rng.gen_range(12.0, 32.0);
+            chunk.cave.0.offset = Vec2::new(
+                ctx.rng.gen_range(-16, 17),
+                ctx.rng.gen_range(-16, 17),
+            );
+        }
+    }
+
     pub fn place(&self, id: Id<Place>) -> &Place { self.places.get(id) }
 
     pub fn sites(&self) -> impl Iterator<Item = &Site> + '_ { self.sites.values() }
@@ -425,16 +485,16 @@ impl Civs {
                                 .expect("Track locations must be neighbors")
                                 .0;
 
-                            ctx.sim.get_mut(locs[0]).unwrap().path.neighbors |=
+                            ctx.sim.get_mut(locs[0]).unwrap().path.0.neighbors |=
                                 1 << ((to_prev_idx as u8 + 4) % 8);
-                            ctx.sim.get_mut(locs[2]).unwrap().path.neighbors |=
+                            ctx.sim.get_mut(locs[2]).unwrap().path.0.neighbors |=
                                 1 << ((to_next_idx as u8 + 4) % 8);
                             let mut chunk = ctx.sim.get_mut(locs[1]).unwrap();
-                            chunk.path.neighbors |=
+                            chunk.path.0.neighbors |=
                                 (1 << (to_prev_idx as u8)) | (1 << (to_next_idx as u8));
-                            chunk.path.offset = Vec2::new(
-                                ctx.rng.gen_range(-16.0, 16.0),
-                                ctx.rng.gen_range(-16.0, 16.0),
+                            chunk.path.0.offset = Vec2::new(
+                                ctx.rng.gen_range(-16, 17),
+                                ctx.rng.gen_range(-16, 17),
                             );
                         }
 
@@ -570,7 +630,7 @@ fn walk_in_dir(sim: &WorldSim, a: Vec2<i32>, dir: Vec2<i32>) -> Option<f32> {
         } else {
             0.0
         };
-        let wild_cost = if b_chunk.path.is_path() {
+        let wild_cost = if b_chunk.path.0.is_way() {
             0.0 // Traversing existing paths has no additional cost!
         } else {
             2.0
diff --git a/world/src/column/mod.rs b/world/src/column/mod.rs
index 129b37545c..7a1cdc72af 100644
--- a/world/src/column/mod.rs
+++ b/world/src/column/mod.rs
@@ -2,7 +2,8 @@ use crate::{
     all::ForestKind,
     block::StructureMeta,
     sim::{
-        local_cells, uniform_idx_as_vec2, vec2_as_uniform_idx, Path, RiverKind, SimChunk, WorldSim,
+        local_cells, uniform_idx_as_vec2, vec2_as_uniform_idx,
+        Path, Cave, RiverKind, SimChunk, WorldSim,
     },
     util::Sampler,
     Index, CONFIG,
@@ -1082,6 +1083,7 @@ where
         };
 
         let path = sim.get_nearest_path(wpos);
+        let cave = sim.get_nearest_cave(wpos);
 
         Some(ColumnSample {
             alt,
@@ -1131,6 +1133,7 @@ where
             stone_col,
             water_dist,
             path,
+            cave,
 
             chunk: sim_chunk,
         })
@@ -1165,6 +1168,7 @@ pub struct ColumnSample<'a> {
     pub stone_col: Rgb<u8>,
     pub water_dist: Option<f32>,
     pub path: Option<(f32, Vec2<f32>, Path, Vec2<f32>)>,
+    pub cave: Option<(f32, Vec2<f32>, Cave, Vec2<f32>)>,
 
     pub chunk: &'a SimChunk,
 }
diff --git a/world/src/layer/mod.rs b/world/src/layer/mod.rs
index 7cb81635e6..565a89b9b5 100644
--- a/world/src/layer/mod.rs
+++ b/world/src/layer/mod.rs
@@ -95,3 +95,40 @@ pub fn apply_paths_to<'a>(
         }
     }
 }
+
+pub fn apply_caves_to<'a>(
+    wpos2d: Vec2<i32>,
+    mut get_column: impl FnMut(Vec2<i32>) -> Option<&'a ColumnSample<'a>>,
+    vol: &mut (impl BaseVol<Vox = Block> + RectSizedVol + ReadVol + WriteVol),
+) {
+    for y in 0..vol.size_xy().y as i32 {
+        for x in 0..vol.size_xy().x as i32 {
+            let offs = Vec2::new(x, y);
+
+            let wpos2d = wpos2d + offs;
+
+            // Sample terrain
+            let col_sample = if let Some(col_sample) = get_column(offs) {
+                col_sample
+            } else {
+                continue;
+            };
+            let surface_z = col_sample.riverless_alt.floor() as i32;
+
+            if let Some((cave_dist, cave_nearest, cave, _)) = col_sample
+                .cave
+                .filter(|(dist, _, cave, _)| *dist < cave.width)
+            {
+                let cave_x = (cave_dist / cave.width).min(1.0);
+                let height = (1.0 - cave_x.powf(2.0)).max(0.0).sqrt() * cave.width;
+
+                for z in (cave.alt - height) as i32..(cave.alt + height) as i32 {
+                    let _ = vol.set(
+                        Vec3::new(offs.x, offs.y, z),
+                        Block::empty(),
+                    );
+                }
+            }
+        }
+    }
+}
diff --git a/world/src/lib.rs b/world/src/lib.rs
index 9aabf4dff8..96d8865da2 100644
--- a/world/src/lib.rs
+++ b/world/src/lib.rs
@@ -175,8 +175,9 @@ impl World {
 
         let mut rng = rand::thread_rng();
 
-        // Apply paths
+        // Apply layers (paths, caves, etc.)
         layer::apply_paths_to(chunk_wpos2d, sample_get, &mut chunk);
+        layer::apply_caves_to(chunk_wpos2d, sample_get, &mut chunk);
 
         // Apply site generation
         sim_chunk.sites.iter().for_each(|site| {
diff --git a/world/src/sim/map.rs b/world/src/sim/map.rs
index ebed7fae80..6655b84e35 100644
--- a/world/src/sim/map.rs
+++ b/world/src/sim/map.rs
@@ -157,6 +157,7 @@ impl MapConfig {
                 downhill,
                 river_kind,
                 is_path,
+                is_cave,
                 near_site,
             ) = sampler
                 .get(pos)
@@ -169,7 +170,8 @@ impl MapConfig {
                         sample.temp,
                         sample.downhill,
                         sample.river.river_kind,
-                        sample.path.is_path(),
+                        sample.path.0.is_way(),
+                        sample.cave.0.is_way(),
                         sample.sites.iter().any(|site| {
                             index.sites[*site]
                                 .get_origin()
@@ -188,6 +190,7 @@ impl MapConfig {
                     None,
                     false,
                     false,
+                    false,
                 ));
             let humidity = humidity.min(1.0).max(0.0);
             let temperature = temperature.min(1.0).max(-1.0) * 0.5 + 0.5;
@@ -317,6 +320,8 @@ impl MapConfig {
                 (0x57, 0x39, 0x33, 0xFF)
             } else if is_path {
                 (0x37, 0x29, 0x23, 0xFF)
+            } else if is_cave {
+                (0x37, 0x37, 0x37, 0xFF)
             } else {
                 rgba
             };
diff --git a/world/src/sim/mod.rs b/world/src/sim/mod.rs
index d90f07d178..584c95dbc0 100644
--- a/world/src/sim/mod.rs
+++ b/world/src/sim/mod.rs
@@ -2,7 +2,7 @@ mod diffusion;
 mod erosion;
 mod location;
 mod map;
-mod path;
+mod way;
 mod util;
 
 // Reexports
@@ -15,7 +15,7 @@ pub use self::{
     },
     location::Location,
     map::{MapConfig, MapDebug},
-    path::{Path, PathData},
+    way::{Way, Path, Cave},
     util::{
         cdf_irwin_hall, downhill, get_oceans, local_cells, map_edge_factor, neighbors,
         uniform_idx_as_vec2, uniform_noise, uphill, vec2_as_uniform_idx, InverseCdf, ScaleBias,
@@ -1700,10 +1700,14 @@ impl WorldSim {
         Some(z0 + z1 + z2 + z3)
     }
 
-    /// Return the distance to the nearest path in blocks, along with the
-    /// closest point on the path, the path metadata, and the tangent vector
-    /// of that path.
-    pub fn get_nearest_path(&self, wpos: Vec2<i32>) -> Option<(f32, Vec2<f32>, Path, Vec2<f32>)> {
+    /// Return the distance to the nearest way in blocks, along with the
+    /// closest point on the way, the way metadata, and the tangent vector
+    /// of that way.
+    pub fn get_nearest_way<M: Clone + Lerp<Output=M>>(
+        &self,
+        wpos: Vec2<i32>,
+        get_way: impl Fn(&SimChunk) -> Option<(Way, M)>,
+    ) -> Option<(f32, Vec2<f32>, M, Vec2<f32>)> {
         let chunk_pos = wpos.map2(TerrainChunkSize::RECT_SIZE, |e, sz: u32| {
             e.div_euclid(sz as i32)
         });
@@ -1713,32 +1717,34 @@ impl WorldSim {
             })
         };
 
+        let get_way = &get_way;
         LOCALITY
             .iter()
             .filter_map(|ctrl| {
-                let chunk = self.get(chunk_pos + *ctrl)?;
-                let ctrl_pos =
-                    get_chunk_centre(chunk_pos + *ctrl).map(|e| e as f32) + chunk.path.offset;
+                let (way, meta) = get_way(self.get(chunk_pos + *ctrl)?)?;
+                let ctrl_pos = get_chunk_centre(chunk_pos + *ctrl).map(|e| e as f32) + way.offset.map(|e| e as f32);
 
-                let chunk_connections = chunk.path.neighbors.count_ones();
+                let chunk_connections = way.neighbors.count_ones();
                 if chunk_connections == 0 {
                     return None;
                 }
 
-                let (start_pos, _start_idx) = if chunk_connections != 2 {
-                    (ctrl_pos, None)
+                let (start_pos, _start_idx, start_meta) = if chunk_connections != 2 {
+                    (ctrl_pos, None, meta.clone())
                 } else {
                     let (start_idx, start_rpos) = NEIGHBORS
                         .iter()
                         .copied()
                         .enumerate()
-                        .find(|(i, _)| chunk.path.neighbors & (1 << *i as u8) != 0)
+                        .find(|(i, _)| way.neighbors & (1 << *i as u8) != 0)
                         .unwrap();
                     let start_pos_chunk = chunk_pos + *ctrl + start_rpos;
+                    let (start_way, start_meta) = get_way(self.get(start_pos_chunk)?)?;
                     (
                         get_chunk_centre(start_pos_chunk).map(|e| e as f32)
-                            + self.get(start_pos_chunk)?.path.offset,
+                            + start_way.offset.map(|e| e as f32),
                         Some(start_idx),
+                        start_meta,
                     )
                 };
 
@@ -1746,11 +1752,12 @@ impl WorldSim {
                     NEIGHBORS
                         .iter()
                         .enumerate()
-                        .filter(move |(i, _)| chunk.path.neighbors & (1 << *i as u8) != 0)
+                        .filter(move |(i, _)| way.neighbors & (1 << *i as u8) != 0)
                         .filter_map(move |(_, end_rpos)| {
                             let end_pos_chunk = chunk_pos + *ctrl + end_rpos;
+                            let (end_way, end_meta) = get_way(self.get(end_pos_chunk)?)?;
                             let end_pos = get_chunk_centre(end_pos_chunk).map(|e| e as f32)
-                                + self.get(end_pos_chunk)?.path.offset;
+                                + end_way.offset.map(|e| e as f32);
 
                             let bez = QuadraticBezier2 {
                                 start: (start_pos + ctrl_pos) / 2.0,
@@ -1763,7 +1770,12 @@ impl WorldSim {
                                 .clamped(0.0, 1.0);
                             let pos = bez.evaluate(nearest_interval);
                             let dist_sqrd = pos.distance_squared(wpos.map(|e| e as f32));
-                            Some((dist_sqrd, pos, chunk.path.path, move || {
+                            let meta = if nearest_interval < 0.5 {
+                                Lerp::lerp(start_meta.clone(), meta.clone(), 0.5 + nearest_interval)
+                            } else {
+                                Lerp::lerp(meta.clone(), end_meta, nearest_interval - 0.5)
+                            };
+                            Some((dist_sqrd, pos, meta, move || {
                                 bez.evaluate_derivative(nearest_interval).normalized()
                             }))
                         }),
@@ -1771,7 +1783,15 @@ impl WorldSim {
             })
             .flatten()
             .min_by_key(|(dist_sqrd, _, _, _)| (dist_sqrd * 1024.0) as i32)
-            .map(|(dist, pos, path, calc_tangent)| (dist.sqrt(), pos, path, calc_tangent()))
+            .map(|(dist, pos, meta, calc_tangent)| (dist.sqrt(), pos, meta, calc_tangent()))
+    }
+
+    pub fn get_nearest_path(&self, wpos: Vec2<i32>) -> Option<(f32, Vec2<f32>, Path, Vec2<f32>)> {
+        self.get_nearest_way(wpos, |chunk| Some(chunk.path))
+    }
+
+    pub fn get_nearest_cave(&self, wpos: Vec2<i32>) -> Option<(f32, Vec2<f32>, Cave, Vec2<f32>)> {
+        self.get_nearest_way(wpos, |chunk| Some(chunk.cave))
     }
 }
 
@@ -1797,7 +1817,10 @@ pub struct SimChunk {
 
     pub sites: Vec<Id<Site>>,
     pub place: Option<Id<Place>>,
-    pub path: PathData,
+
+    pub path: (Way, Path),
+    pub cave: (Way, Cave),
+
     pub contains_waypoint: bool,
 }
 
@@ -2033,7 +2056,8 @@ impl SimChunk {
 
             sites: Vec::new(),
             place: None,
-            path: PathData::default(),
+            path: Default::default(),
+            cave: Default::default(),
             contains_waypoint: false,
         }
     }
diff --git a/world/src/sim/path.rs b/world/src/sim/path.rs
deleted file mode 100644
index 3756b14dc6..0000000000
--- a/world/src/sim/path.rs
+++ /dev/null
@@ -1,41 +0,0 @@
-use vek::*;
-
-#[derive(Copy, Clone, Debug)]
-pub struct Path {
-    pub width: f32, // Actually radius
-}
-
-#[derive(Debug)]
-pub struct PathData {
-    pub offset: Vec2<f32>, /* Offset from centre of chunk: must not be more than half chunk
-                            * width in any direction */
-    pub path: Path,
-    pub neighbors: u8, // One bit for each neighbor
-}
-
-impl PathData {
-    pub fn is_path(&self) -> bool { self.neighbors != 0 }
-
-    pub fn clear(&mut self) { self.neighbors = 0; }
-}
-
-impl Default for PathData {
-    fn default() -> Self {
-        Self {
-            offset: Vec2::zero(),
-            path: Path { width: 5.0 },
-            neighbors: 0,
-        }
-    }
-}
-
-impl Path {
-    /// Return the number of blocks of headspace required at the given path
-    /// distance
-    pub fn head_space(&self, dist: f32) -> i32 {
-        (8 - (dist * 0.25).powf(6.0).round() as i32).max(1)
-    }
-
-    /// Get the surface colour of a path given the surrounding surface color
-    pub fn surface_color(&self, col: Rgb<u8>) -> Rgb<u8> { col.map(|e| (e as f32 * 0.7) as u8) }
-}
diff --git a/world/src/sim/way.rs b/world/src/sim/way.rs
new file mode 100644
index 0000000000..bc573a4b17
--- /dev/null
+++ b/world/src/sim/way.rs
@@ -0,0 +1,72 @@
+use vek::*;
+
+#[derive(Copy, Clone, Debug, Default)]
+pub struct Way {
+	/// Offset from chunk center in blocks (no more than half chunk width)
+	pub offset: Vec2<i8>,
+	/// Neighbor connections, one bit each
+	pub neighbors: u8,
+}
+
+impl Way {
+    pub fn is_way(&self) -> bool { self.neighbors != 0 }
+
+    pub fn clear(&mut self) { self.neighbors = 0; }
+}
+
+#[derive(Copy, Clone, Debug)]
+pub struct Path {
+    pub width: f32, // Actually radius
+}
+
+impl Default for Path {
+    fn default() -> Self {
+        Self { width: 5.0 }
+    }
+}
+
+impl Lerp for Path {
+    type Output = Self;
+
+    fn lerp_unclamped(from: Self, to: Self, factor: f32) -> Self::Output {
+        Self { width: Lerp::lerp(from.width, to.width, factor) }
+    }
+}
+
+impl Path {
+    /// Return the number of blocks of headspace required at the given path
+    /// distance
+	/// TODO: make this generic over width
+    pub fn head_space(&self, dist: f32) -> i32 {
+        (8 - (dist * 0.25).powf(6.0).round() as i32).max(1)
+    }
+
+    /// Get the surface colour of a path given the surrounding surface color
+    pub fn surface_color(&self, col: Rgb<u8>) -> Rgb<u8> { col.map(|e| (e as f32 * 0.7) as u8) }
+}
+
+#[derive(Copy, Clone, Debug)]
+pub struct Cave {
+    pub width: f32, // Actually radius
+    pub alt: f32, // Actually radius
+}
+
+impl Default for Cave {
+    fn default() -> Self {
+        Self {
+            width: 32.0,
+            alt: 0.0,
+        }
+    }
+}
+
+impl Lerp for Cave {
+    type Output = Self;
+
+    fn lerp_unclamped(from: Self, to: Self, factor: f32) -> Self::Output {
+        Self {
+            width: Lerp::lerp(from.width, to.width, factor),
+            alt: Lerp::lerp(from.alt, to.alt, factor),
+        }
+    }
+}
diff --git a/world/src/sim2/mod.rs b/world/src/sim2/mod.rs
index 1a45d48511..3d07e8ad34 100644
--- a/world/src/sim2/mod.rs
+++ b/world/src/sim2/mod.rs
@@ -121,7 +121,7 @@ pub fn tick_site_economy(index: &mut Index, site: Id<Site>, dt: f32) {
         }
     }
 
-    let mut supply = site.economy.stocks.clone();//MapVec::from_default(0.0);
+    let mut supply = site.economy.stocks.clone(); //MapVec::from_default(0.0);
     for (labor, (output_good, _)) in productivity.iter() {
         supply[*output_good] +=
             site.economy.yields[labor] * site.economy.labors[labor] * site.economy.pop;
@@ -231,7 +231,8 @@ pub fn tick_site_economy(index: &mut Index, site: Id<Site>, dt: f32) {
             let (stock, rate) = productivity[*labor];
             let workers = site.economy.labors[*labor] * site.economy.pop;
             let final_rate = rate;
-            let yield_per_worker = labor_productivity * final_rate * (1.0 + workers / 100.0).min(3.0);
+            let yield_per_worker =
+                labor_productivity * final_rate * (1.0 + workers / 100.0).min(3.0);
             site.economy.yields[*labor] = yield_per_worker;
             site.economy.productivity[*labor] = labor_productivity;
             let total_output = yield_per_worker * workers;
diff --git a/world/src/site/economy.rs b/world/src/site/economy.rs
index 2c59d5c31b..f152560702 100644
--- a/world/src/site/economy.rs
+++ b/world/src/site/economy.rs
@@ -102,9 +102,15 @@ impl Economy {
 
     pub fn replenish(&mut self, time: f32) {
         use rand::Rng;
-        for (i, (g, v)) in [(Wheat, 50.0), (Logs, 20.0), (Rock, 120.0), (Game, 12.0), (Fish, 10.0)]
-            .iter()
-            .enumerate()
+        for (i, (g, v)) in [
+            (Wheat, 50.0),
+            (Logs, 20.0),
+            (Rock, 120.0),
+            (Game, 12.0),
+            (Fish, 10.0),
+        ]
+        .iter()
+        .enumerate()
         {
             self.stocks[*g] = (*v
                 * (1.25 + (((time * 0.0001 + i as f32).sin() + 1.0) % 1.0) * 0.5)