Repository: SoftbearStudios/mk48 (language: rust)

Issue:
Shallow water - sanctuary for small boats
Currently, the terrain generator generates waters of varying depth, but this has almost no impact on gameplay. I propose that the draft of each ship be taken into account, such that shallow areas are only viable for small, lower level boats. This would protect lower level boats so that they can fight each other, safe from higher level boats.

The obvious questions are:
- **What happens when a big ship enters water that is too shallow?** I propose that it should slow down to near terrain-collision speed (but not take damage unless it actually hits land)
- **What happens if you try to upgrade to a ship with a higher draft than the water allows?** I propose either the upgrade is blocked OR a warning is displayed (and the slowness penalty is applied if the player chooses to go through with it)
- **What prevents higher level boats from shooting long range weapons into shallow areas?** Possibly terrain (winding, shallow rivers into shallow areas could block most projectiles). Definitely distance.

Some side effects:
- Ships that are not respawning with a team and do not have the required points to upgrade to a big ship will respawn in a shallow area
- Submarine depth will be limited by water depth (which makes subs more visible in shallow areas)
- Terrain generator needs changes (or a map must be manually created) to induce shallow areas


Discussion / hints:

Fix patch:
diff --git a/client/src/lib/EntityCard.svelte b/client/src/lib/EntityCard.svelte
index 4be0b8c0..41440bab 100644
--- a/client/src/lib/EntityCard.svelte
+++ b/client/src/lib/EntityCard.svelte
@@ -40,6 +40,9 @@
 				{#if entityData.length}
 					<li>Length: {entityData.length.toFixed(1)}m</li>
 				{/if}
+				{#if entityData.draft}
+					<li>Draft: {entityData.draft.toFixed(1)}m</li>
+				{/if}
 				{#if entityData.speed}
 					<li>Speed: {entityData.speed.toFixed(1)}m/s ({toKnotsString(entityData.speed)})</li>
 				{/if}
diff --git a/client/src/lib/background.js b/client/src/lib/background.js
index 0d86c8a0..9d365bbb 100644
--- a/client/src/lib/background.js
+++ b/client/src/lib/background.js
@@ -41,24 +41,29 @@ float noise(vec3 p){
 	return o4.y * d.y + o4.x * (1.0 - d.y);
 }
 
+// Match Go code "levels"
+#define SAND 0.5
+#define GRASS 0.5608
+
+#define SHOAL (SAND - 0.0075)
+#define DEEP 0.3
+#define SHARPNESS 1.5
+#define GRASS_SATURATION 2.0
+
 void main() {
 	vec2 worldPos = vec2(iOffset.x + iScale.x * gl_FragCoord.x, iOffset.y - iScale.y * gl_FragCoord.y);
 	float h = texture2D(iTerrain, (worldPos - iTerrainDimensions.xy) / iTerrainDimensions.zw).a;
 	float nHeight = noise(vec3(worldPos.x / 10.0, worldPos.y / 10.0, 0));
 	float height = h + nHeight * 0.02 + 0.01;
 
-	if (height > 0.64) {
-		gl_FragColor = vec4(mix(vec3(0.41, 0.43, 0.45), vec3(0.87), min((height - 0.64) / 0.2, 1.0)), 1.0); // Snow
-	} else if (height > 0.48) {
-		gl_FragColor = vec4(mix(vec3(0.35, 0.6, 0.25), vec3(0.41, 0.43, 0.45), min((height - 0.48) / 0.15, 1.0)), 1.0); // Rock
-	} else if (h + nHeight * 0.01 > 0.29) {
-		gl_FragColor = vec4(mix(vec3(0.76, 0.7, 0.5), vec3(0.35, 0.6, 0.25), min((h + nHeight * 0.01 - 0.29) / 0.1, 1.0)), 1.0); // Grass
-	} else if (height > 0.25) {
-		gl_FragColor = vec4(mix(vec3(0.63, 0.55, 0.4), vec3(0.76, 0.7, 0.5), min((height - 0.25) / 0.015, 1.0)), 1.0); // Sand
-	} else if (height > 0.245) {
-		gl_FragColor = vec4(mix(vec3(0.0, 0.3, 0.5), vec3(0.63, 0.55, 0.4), (height - 0.245) / 0.005), 1.0); // Water to sand
+	if (height >= GRASS) {
+		gl_FragColor = vec4(mix(vec3(0.73, 0.65, 0.45), vec3(0.25, 0.6 + sin(worldPos.x * 0.075 + 0.5 * cos(worldPos.y * 0.05)) * 0.005, 0.2), min((height - GRASS) * GRASS_SATURATION / (1.0 - GRASS), 1.0)), 1.0); // Grass
+	} else if (height >= SAND) {
+		gl_FragColor = vec4(mix(vec3(0.73, 0.68, 0.43), vec3(0.73, 0.65, 0.45), min((height - SAND) * SHARPNESS / (GRASS - SAND), 1.0)), 1.0); // Sand
+	} else if (height > SHOAL) {
+		gl_FragColor = vec4(mix(vec3(0.0, 0.3, 0.5), vec3(0.73, 0.68, 0.5), (height - SHOAL) / (SAND - SHOAL)), 1.0); // Water to sand
 	} else {
-		gl_FragColor = vec4(mix(vec3(0.0, 0.2, 0.45), vec3(0.0, 0.3, 0.5), height / 0.245), 1.0); // Water
+		gl_FragColor = vec4(mix(vec3(0.0, 0.2, 0.45), vec3(0.0, 0.3, 0.5), max((height - DEEP) / (SHOAL - DEEP), -0.35)), 1.0); // Water
 	}
 
 	gl_FragColor = mix(gl_FragColor, vec4(1.0, 0.0, 0.0, 1.0), max(min((length(worldPos) - iBorderRange) * 0.5, 0.25), 0.0) * (mod(worldPos.x + worldPos.y + iTime * 20.0, 50.0) > 25.0 ? 1.0 : 0.75));
diff --git a/client/src/routes/changelog.svelte b/client/src/routes/changelog.svelte
index 6e912c15..76b56082 100644
--- a/client/src/routes/changelog.svelte
+++ b/client/src/routes/changelog.svelte
@@ -15,6 +15,14 @@
 
 	<h2>2021</h2>
 
+	<h3>6/15/2021</h3>
+
+	<ul>
+		<li>Add shallow water, which reduces the speed and health regeneration of
+		larger boats (and the maximum depth of submarines). Small boats that are
+		not in a team are likely to spawn in these areas.</li>
+	</ul>
+
 	<h3>6/14/2021</h3>
 
 	<ul>
diff --git a/data/entities-raw.json b/data/entities-raw.json
index 8831a863..57a4b4a5 100644
--- a/data/entities-raw.json
+++ b/data/entities-raw.json
@@ -66,6 +66,7 @@
 		"level": 5,
 		"length": 113.3,
 		"width": 20.137,
+		"draft": 9.7,
 		"speed": 18.00556,
 		"sensors": {
 			"visual": {},
@@ -115,6 +116,7 @@
 		"level": 4,
 		"length": 154,
 		"width": 20,
+		"draft": 9.3,
 		"speed": 17,
 		"stealth": 0.25,
 		"sensors": {
@@ -235,6 +237,7 @@
 		"level": 6,
 		"length": 241.6,
 		"width": 36,
+		"draft": 9.3,
 		"speed": 15.438478,
 		"sensors": {
 			"visual": {},
@@ -286,6 +289,7 @@
 		"level": 4,
 		"length": 99,
 		"width": 16.5,
+		"draft": 6.4,
 		"speed": 8,
 		"sensors": {
 			"visual": {}
@@ -313,6 +317,7 @@
 		"level": 6,
 		"length": 265.8,
 		"width": 42.5695,
+		"draft": 7,
 		"speed": 16.83333,
 		"sensors": {
 			"visual": {},
@@ -393,6 +398,7 @@
 		"level": 1,
 		"length": 35,
 		"width": 6.35,
+		"draft": 1.45,
 		"speed": 15.9477,
 		"sensors": {
 			"visual": {}
@@ -452,6 +458,7 @@
 		"level": 3,
 		"length": 114.8,
 		"width": 12,
+		"draft": 5.3,
 		"speed": 18.777,
 		"sensors": {
 			"visual": {},
@@ -599,6 +606,7 @@
 		"level": 6,
 		"length": 115,
 		"width": 17.5,
+		"draft": 3.9,
 		"speed": 24.1789,
 		"stealth": 0.5,
 		"sensors": {
@@ -654,6 +662,7 @@
 		"level": 1,
 		"length": 18.85,
 		"width": 3.5,
+		"draft": 0.82,
 		"speed": 27.26557,
 		"sensors": {
 			"visual": {}
@@ -678,6 +687,7 @@
 		"level": 3,
 		"length": 98.4,
 		"width": 8.2,
+		"draft": 8.5,
 		"speed": 8.7455,
 		"sensors": {
 			"visual": {},
@@ -721,6 +731,7 @@
 		"level": 3,
 		"length": 52.8143,
 		"width": 13.6162,
+		"draft": 5,
 		"speed": 4,
 		"armaments": [
 			{
@@ -776,6 +787,7 @@
 		"level": 5,
 		"length": 163,
 		"width": 17.4,
+		"draft": 6.5,
 		"speed": 15.43334,
 		"stealth": 0.5,
 		"sensors": {
@@ -858,6 +870,7 @@
 		"level": 1,
 		"length": 25.4,
 		"width": 6.24,
+		"draft": 1.24,
 		"speed": 22.6,
 		"sensors": {
 			"visual": {},
@@ -913,6 +926,7 @@
 		"level": 4,
 		"length": 169.1,
 		"width": 17.1,
+		"draft": 5.8,
 		"speed": 16.71945,
 		"sensors": {
 			"visual": {},
@@ -1009,6 +1023,7 @@
 		"level": 3,
 		"length": 95.8,
 		"width": 10.8,
+		"draft": 2.38,
 		"speed": 8.5,
 		"sensors": {
 			"visual": {},
@@ -1060,6 +1075,7 @@
 		"level": 2,
 		"length": 85.3,
 		"width": 7.9,
+		"draft": 2.4,
 		"speed": 18.52,
 		"sensors": {
 			"visual": {},
@@ -1144,6 +1160,7 @@
 		"level": 7,
 		"length": 280.80,
 		"width": 36.93,
+		"draft": 10.97,
 		"speed": 14.404,
 		"sensors": {
 			"visual": {},
@@ -1195,6 +1212,7 @@
 		"level": 7,
 		"length": 189,
 		"width": 34,
+		"draft": 7.7,
 		"speed": 14.66167,
 		"sensors": {
 			"visual": {},
@@ -1214,12 +1232,6 @@
 				"positionSide": 8.0173,
 				"external": true
 			},
-			{
-				"type": "ka25",
-				"positionForward": -51.5997,
-				"positionSide": 0.800947,
-				"external": true
-			},
 			{
 				"type": "ka25",
 				"positionForward": -64.7966,
@@ -1290,6 +1302,7 @@
 		"level": 6,
 		"length": 170,
 		"width": 13,
+		"draft": 10.8,
 		"speed": 12.8611,
 		"sensors": {
 			"visual": {},
@@ -1378,6 +1391,7 @@
 		"level": 1,
 		"length": 36.9,
 		"width": 5.5,
+		"draft": 1.25,
 		"speed": 16,
 		"sensors": {
 			"visual": {}
@@ -1391,6 +1405,7 @@
 		"level": 3,
 		"length": 38.6,
 		"width": 7.64,
+		"draft": 1.73,
 		"speed": 21.6067,
 		"sensors": {
 			"visual": {},
@@ -1439,6 +1454,7 @@
 		"level": 1,
 		"length": 23,
 		"width": 6.07,
+		"draft": 1.37,
 		"speed": 21.09,
 		"sensors": {
 			"visual": {}
@@ -1472,6 +1488,7 @@
 		"level": 4,
 		"length": 76.71,
 		"width": 9.65,
+		"draft": 4.825,
 		"speed": 16.976667,
 		"sensors": {
 			"visual": {},
@@ -1524,6 +1541,7 @@
 		"length": 179,
 		"width": 30.940,
 		"speed": 8.333333,
+		"draft": 11.6,
 		"sensors": {
 			"visual": {}
 		},
@@ -1537,6 +1555,7 @@
 		"level": 5,
 		"length": 180.3,
 		"width": 20.77676,
+		"draft": 6.28,
 		"speed": 16.59084,
 		"sensors": {
 			"visual": {},
@@ -1637,6 +1656,7 @@
 		"level": 2,
 		"length": 67.1,
 		"width": 6.2,
+		"draft": 4.74,
 		"speed": 9.06,
 		"sensors": {
 			"visual": {},
@@ -1689,6 +1709,7 @@
 		"level": 5,
 		"length": 72.7,
 		"width": 10.4,
+		"draft": 2.4,
 		"speed": 18.00556,
 		"stealth": 0.75,
 		"sensors": {
@@ -1802,6 +1823,7 @@
 		"level": 7,
 		"length": 130,
 		"width": 19.8046875,
+		"draft": 10,
 		"speed": 18.00556,
 		"sensors": {
 			"visual": {},
@@ -1869,6 +1891,7 @@
 		"level": 2,
 		"length": 57,
 		"width": 21.15234375,
+		"draft": 1.6,
 		"speed": 28.29446,
 		"sensors": {
 			"visual": {},
@@ -1898,6 +1921,7 @@
 		"level": 6,
 		"length": 190,
 		"width": 24.6,
+		"draft": 8.4,
 		"speed": 15.43333,
 		"stealth": 0.75,
 		"sensors": {
diff --git a/examples/bot.go b/examples/bot.go
index f7249525..60d57ef5 100644
--- a/examples/bot.go
+++ b/examples/bot.go
@@ -117,7 +117,7 @@ func (b *Bot) sendToHub(inbound server.Inbound) {
 // Red channel = enemy/danger
 // Green channel = obstacle/land
 // Blue channel = friendly/collectible
-func rasterize(ship server.Contact, contacts []server.IDContact, t terrain.Terrain, scale float32, resolution int) image.Image {
+func rasterize(ship server.Contact, contacts []server.IDContact, t *terrain.Terrain, scale float32, resolution int) image.Image {
 	img := image.NewRGBA(image.Rect(0, 0, resolution, resolution))
 	scale /= float32(resolution)
 
@@ -127,7 +127,7 @@ func rasterize(ship server.Contact, contacts []server.IDContact, t terrain.Terra
 			pos := ship.Position
 			pos.X += float32(x-resolution/2) * scale
 			pos.Y += float32(y-resolution/2) * scale
-			if terrain.LandAtPos(t, pos) {
+			if t.LandAt(pos) {
 				bg.G = 255
 			}
 			img.SetRGBA(x, y, bg)
diff --git a/server/bot_client.go b/server/bot_client.go
index 3a7daeb2..4fa6bd7e 100644
--- a/server/bot_client.go
+++ b/server/bot_client.go
@@ -4,7 +4,6 @@
 package server
 
 import (
-	"github.com/SoftbearStudios/mk48/server/terrain"
 	"github.com/SoftbearStudios/mk48/server/world"
 	"github.com/chewxy/math32"
 	"io"
@@ -355,7 +354,7 @@ func (bot *BotClient) isLandInDirection(pos world.Vec2f, length float32, angle w
 		return true
 	}
 
-	return terrain.LandAtPos(bot.Hub.terrain, inFront)
+	return bot.Hub.terrain.LandAt(inFront)
 }
 
 func randomType(r *rand.Rand, entityTypes []world.EntityType) world.EntityType {
diff --git a/server/cloud.go b/server/cloud.go
index 107f984c..1d777510 100644
--- a/server/cloud.go
+++ b/server/cloud.go
@@ -70,7 +70,7 @@ func (h *Hub) Cloud() {
 		if !client.Bot() {
 			playerCount++
 			player := &client.Data().Player
-			if player.Score > 0 {
+			if player.Score > playerScores[player.Name] {
 				playerScores[player.Name] = player.Score
 			}
 		}
diff --git a/server/debug.go b/server/debug.go
index 0de353db..cdf90482 100644
--- a/server/debug.go
+++ b/server/debug.go
@@ -7,7 +7,6 @@ import (
 	"bytes"
 	"fmt"
 	"github.com/SoftbearStudios/mk48/server/terrain"
-	"github.com/SoftbearStudios/mk48/server/terrain/compressed"
 	"github.com/SoftbearStudios/mk48/server/world"
 	"image/png"
 	"runtime"
@@ -145,7 +144,7 @@ func (h *Hub) SnapshotTerrain() {
 		return
 	}
 
-	img := terrain.Render(h.terrain, compressed.Size/4)
+	img := h.terrain.Render(terrain.Size / 4)
 	var buf bytes.Buffer
 	err := png.Encode(&buf, img)
 	if err != nil {
diff --git a/server/hub.go b/server/hub.go
index 82d31ea2..d7997961 100644
--- a/server/hub.go
+++ b/server/hub.go
@@ -6,7 +6,6 @@ package server
 import (
 	"fmt"
 	"github.com/SoftbearStudios/mk48/server/terrain"
-	"github.com/SoftbearStudios/mk48/server/terrain/compressed"
 	"github.com/SoftbearStudios/mk48/server/terrain/noise"
 	"github.com/SoftbearStudios/mk48/server/world"
 	"github.com/SoftbearStudios/mk48/server/world/sector"
@@ -22,7 +21,7 @@ const (
 	spawnPeriod       = leaderboardPeriod
 	updatePeriod      = world.TickPeriod
 
-	// Must spawn atleast this many bots per real player,
+	// Must spawn at least this many bots per real player,
 	// to give low-level ships some easier targets
 	minBotRatio = 0.5
 
@@ -44,7 +43,7 @@ type (
 		// World state
 		world       *sector.World
 		worldRadius float32 // interpolated
-		terrain     terrain.Terrain
+		terrain     *terrain.Terrain
 		clients     ClientList // implemented as double-linked list
 		despawn     ClientList // clients that are being removed
 		teams       map[world.TeamID]*Team
@@ -89,7 +88,7 @@ func NewHub(options HubOptions) *Hub {
 	return &Hub{
 		cloud:             options.Cloud,
 		world:             sector.New(radius),
-		terrain:           compressed.New(noise.NewDefault()),
+		terrain:           terrain.New(noise.NewDefault()),
 		worldRadius:       radius,
 		teams:             make(map[world.TeamID]*Team),
 		minPlayers:        options.MinClients,
@@ -116,7 +115,7 @@ func (h *Hub) Register(client Client) {
 	}
 }
 
-func (h *Hub) GetTerrain() terrain.Terrain {
+func (h *Hub) GetTerrain() *terrain.Terrain {
 	return h.terrain
 }
 
@@ -149,7 +148,7 @@ func (h *Hub) Run() {
 		if r := recover(); r != nil {
 			panic(r)
 		}
-		println("That's it, I'm out -hub") // Don't waste time debugging hub exists
+		println("That's it, I'm out -hub") // Don't waste time debugging hub exits
 		os.Exit(1)
 	}()
 
diff --git a/server/physics.go b/server/physics.go
index 9d1aba56..80a29335 100644
--- a/server/physics.go
+++ b/server/physics.go
@@ -18,7 +18,7 @@ func (h *Hub) Physics(ticks world.Ticks) {
 	timeDeltaSeconds := min(ticks.Float(), 1.0)
 
 	{
-		terrain := world.Collider(h.terrain)
+		terrain := world.Terrain(h.terrain)
 
 		var outputWait sync.WaitGroup
 
diff --git a/server/spawn.go b/server/spawn.go
index 2c437a2a..11ecc3a7 100644
--- a/server/spawn.go
+++ b/server/spawn.go
@@ -130,29 +130,33 @@ func (h *Hub) spawnEntity(entity *world.Entity, initialRadius float32) world.Ent
 		center := entity.Position
 		threshold := float32(5.0)
 
-		// Always randomize on first iteration
 		governor := 0
-		for entity.Position == center || (entity.Data().Kind != world.EntityKindCollectible &&
-			entity.Data().Kind != world.EntityKindWeapon && h.nearAny(entity, threshold)) {
 
+		// Always randomize on first iteration
+		for entity.Position == center || !h.canSpawn(entity, threshold) {
+			// Pick a new position
 			position := world.RandomAngle().Vec2f().Mul(math32.Sqrt(rand.Float32()) * radius)
 			entity.Position = center.Add(position)
 			entity.Direction = world.RandomAngle()
 
 			radius = min(radius*1.1, h.worldRadius*0.9)
-			threshold = 0.25 + threshold*0.75 // Approaches 1.0
+			threshold = 0.15 + threshold*0.85 // Approaches 1.0
 
 			governor++
 			if governor > 128 {
 				// Don't take down the server just because cannnot
 				// spawn an entity
-				return world.EntityIDInvalid
+				break
 			}
 		}
 
 		entity.DirectionTarget = entity.Direction
 	}
 
+	if !h.canSpawn(entity, 1) {
+		return world.EntityIDInvalid
+	}
+
 	// Outside world
 	if entity.Position.LengthSquared() > h.worldRadius*h.worldRadius {
 		return world.EntityIDInvalid
@@ -173,14 +177,36 @@ func (h *Hub) spawnEntity(entity *world.Entity, initialRadius float32) world.Ent
 }
 
 // nearAny Returns if any entities are within a threshold for spawning (or if colliding with terrain)
-func (h *Hub) nearAny(entity *world.Entity, threshold float32) bool {
+func (h *Hub) canSpawn(entity *world.Entity, threshold float32) bool {
+	switch entity.Data().Kind {
+	case world.EntityKindCollectible, world.EntityKindDecoy, world.EntityKindWeapon:
+		// Weapons spawn where the player shoots them regardless of entities,
+		// Collectibles don't care about colliding with entities while spawning
+
+		// Simply perform a terrain check against the current position (no slow conservative check)
+		return !h.terrain.Collides(entity, 0)
+	case world.EntityKindBoat:
+		// Be picky about spawning in appropriate depth water
+		// unless threshold is very low
+		// Ignore if owner has a team or enough points to upgrade to bigger ship
+		if threshold > 1.5 && (entity.Owner == nil || (entity.Owner.TeamID == world.TeamIDInvalid && entity.Owner.Score < world.LevelToScore(2))) {
+			belowKeel := entity.BelowKeel(h.terrain)
+			if belowKeel < 0 || belowKeel > 5 {
+				return false
+			}
+		}
+	}
+
+	// Slow, conservative check
+	if h.terrain.Collides(entity, -1) {
+		return false
+	}
+
 	// Extra space between entities
 	radius := entity.Data().Radius
 	maxT := (radius + world.EntityRadiusMax) * threshold
 
-	collides := h.terrain.Collides(entity, -1)
-
-	return collides || h.world.ForEntitiesInRadius(entity.Position, maxT, func(r float32, otherEntity *world.Entity) (stop bool) {
+	return !h.world.ForEntitiesInRadius(entity.Position, maxT, func(r float32, otherEntity *world.Entity) (stop bool) {
 		t := (radius + otherEntity.Data().Radius) * threshold
 		return r < t*t
 	})
diff --git a/server/terrain/altitude.go b/server/terrain/altitude.go
new file mode 100644
index 00000000..0da63924
--- /dev/null
+++ b/server/terrain/altitude.go
@@ -0,0 +1,29 @@
+// SPDX-FileCopyrightText: 2021 Softbear, Inc.
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package terrain
+
+import "github.com/SoftbearStudios/mk48/server/world"
+
+const (
+	// Based on how many bits represent a terrain altitude
+	resolution = 1 << 4
+
+	// Start of sand layer (right above water)
+	SandLevel = 255 / 2
+
+	// Start of grass layer (right above sand)
+	GrassLevel = SandLevel + 1*resolution
+)
+
+// Returns altitude (in meters) above sea level
+func (t *Terrain) AltitudeAt(pos world.Vec2f) float32 {
+	// 0.3 is a kludge factor, with the goal of making shallow water approximately
+	// 6-8 meters deep
+	return (float32(t.AtPos(pos)) - SandLevel) * 0.3
+}
+
+// Returns whether the position lies in land (sand or higher)
+func (t *Terrain) LandAt(pos world.Vec2f) bool {
+	return t.AtPos(pos) >= SandLevel
+}
diff --git a/server/terrain/compressed/buffer.go b/server/terrain/buffer.go
similarity index 99%
rename from server/terrain/compressed/buffer.go
rename to server/terrain/buffer.go
index 47fdaaef..22e20131 100644
--- a/server/terrain/compressed/buffer.go
+++ b/server/terrain/buffer.go
@@ -1,7 +1,7 @@
 // SPDX-FileCopyrightText: 2021 Softbear, Inc.
 // SPDX-License-Identifier: AGPL-3.0-or-later
 
-package compressed
+package terrain
 
 import "io"
 
diff --git a/server/terrain/compressed/chunk.go b/server/terrain/chunk.go
similarity index 91%
rename from server/terrain/compressed/chunk.go
rename to server/terrain/chunk.go
index 0a67f301..2265b992 100644
--- a/server/terrain/compressed/chunk.go
+++ b/server/terrain/chunk.go
@@ -1,11 +1,7 @@
 // SPDX-FileCopyrightText: 2021 Softbear, Inc.
 // SPDX-License-Identifier: AGPL-3.0-or-later
 
-package compressed
-
-import (
-	"github.com/SoftbearStudios/mk48/server/terrain"
-)
+package terrain
 
 const (
 	// chunkSizeBits is the chunk size in bits.
@@ -24,7 +20,7 @@ type chunk struct {
 }
 
 // If c passed in, it is partially regenerated (atomically)
-func generateChunk(generator terrain.Source, cx, cy uint, c *chunk) *chunk {
+func generateChunk(generator Source, cx, cy uint, c *chunk) *chunk {
 	heightmap := generator.Generate(int(cx*chunkSize), int(cy*chunkSize), chunkSize, chunkSize)
 
 	// Early bounds check
diff --git a/server/terrain/compressed/compressed.go b/server/terrain/compressed/compressed.go
deleted file mode 100644
index 9fd4005e..00000000
--- a/server/terrain/compressed/compressed.go
+++ /dev/null
@@ -1,298 +0,0 @@
-// SPDX-FileCopyrightText: 2021 Softbear, Inc.
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-package compressed
-
-import (
-	"fmt"
-	"github.com/SoftbearStudios/mk48/server/terrain"
-	"github.com/SoftbearStudios/mk48/server/world"
-	"math/rand"
-	"sync"
-	"sync/atomic"
-	"time"
-	"unsafe"
-)
-
-const (
-	Size       = 2048
-	chunkCount = Size / chunkSize
-)
-
-// Coordinate types
-// World coordinates:            world.Entity.Position
-// Terrain coordinates:          World coordinates / terrain.Scale
-// Unsigned terrain coordinates: Terrain coordinates + Size / 2
-// Chunk coordinates:            Unsigned terrain coordinates / chunkSize
-
-// Terrain is a compressed implementation of terrain.Terrain.
-// It represents each terrain pixel with 4 bits of precision.
-type Terrain struct {
-	generator  terrain.Source
-	chunks     [chunkCount][chunkCount]*chunk
-	chunkCount int32
-	mutex      sync.Mutex
-}
-
-// New creates a new Terrain from a source.
-func New(source terrain.Source) *Terrain {
-	return &Terrain{
-		generator: source,
-	}
-}
-
-// clampAABB returns a signed terrain coords aabb and unsigned terrain coords ux, uy, width, and height.
-func clampAABB(aabb world.AABB) (aabb2 world.AABB, ux, uy, width, height uint) {
-	p := aabb.Vec2f.Mul(1.0 / terrain.Scale).Sub(world.Vec2f{X: 1.0, Y: 1.0}).Floor()
-
-	// Signed terrain coords
-	x := maxInt(-Size/2, int(p.X))
-	y := maxInt(-Size/2, int(p.Y))
-
-	if x >= Size/2 || y >= Size/2 {
-		return
-	}
-
-	s := world.Vec2f{X: aabb.Width, Y: aabb.Height}.Mul(1.0 / terrain.Scale).Add(world.Vec2f{X: 2.0, Y: 2.0}).Ceil()
-	ux = uint(x + Size/2)
-	uy = uint(y + Size/2)
-	width = uint(minInt(Size/2-x, int(s.X)))
-	height = uint(minInt(Size/2-y, int(s.Y)))
-
-	aabb2 = world.AABB{
-		Vec2f: world.Vec2f{
-			X: float32(x) - 0.5,
-			Y: float32(y) - 0.5,
-		}.Mul(terrain.Scale),
-		Width:  float32(width) * terrain.Scale,
-		Height: float32(height) * terrain.Scale,
-	}
-
-	return
-}
-
-// Clamp clamps a bounding box to what At will send.
-// It's useful for caching terrain data.
-func (t *Terrain) Clamp(aabb world.AABB) world.AABB {
-	clamped, _, _, _, _ := clampAABB(aabb)
-	return clamped
-}
-
-// At returns compressed terrain data at a given bounding box.
-func (t *Terrain) At(aabb world.AABB) *terrain.Data {
-	clamped, x, y, width, height := clampAABB(aabb)
-
-	data := terrain.NewData()
-	buffer := Buffer{
-		buf: data.Data,
-	}
-
-	for j := y; j < height+y; j++ {
-		for i := x; i < width+x; i++ {
-			buffer.writeByte(t.at(i, j))
-		}
-	}
-
-	data.AABB = clamped
-	data.Data = buffer.Buffer()
-	data.Stride = int(width)
-	data.Length = int(width * height)
-
-	return data
-}
-
-// Decode decompresses terrain data compressed with this terrain.
-func (t *Terrain) Decode(data *terrain.Data) (raw []byte, err error) {
-	var buf Buffer
-	buf.Reset(data.Data)
-	raw = make([]byte, data.Length)
-	_, err = buf.Read(raw)
-	return
-}
-
-// Repair reverts some of the terrain closer to its original generated state.
-func (t *Terrain) Repair() {
-	millis := time.Now().UnixNano() / int64(time.Millisecond/time.Nanosecond)
-	for ucx, chunks := range t.chunks {
-		for ucy, c := range chunks {
-			if c != nil && millis >= c.regen {
-				if c.regen != 0 { // Don't regen the first time
-					generateChunk(t.generator, uint(ucx)-Size/chunkSize/2, uint(ucy)-Size/chunkSize/2, c)
-				}
-				c.regen = millis + regenMillis + int64(rand.Intn(10000)) // add some randomness to avoid simultaneous regen
-			}
-		}
-	}
-}
-
-// Collides returns if an entity collides with the terrain given a time step in seconds.
-func (t *Terrain) Collides(entity *world.Entity, seconds float32) bool {
-	data := entity.Data()
-	// -6 is ludge offset to make math line up with client
-	threshold := byte(terrain.OceanLevel) - 6
-	if entity.Altitude() > 0 {
-		threshold = terrain.SandLevel
-	}
-
-	normal := entity.Direction.Vec2f()
-	tangent := normal.Rot90()
-
-	position := entity.Position
-	dimensions := world.Vec2f{X: data.Length, Y: data.Width}
-	dx := min(terrain.Scale*2/3, dimensions.X*0.499)
-	dy := min(terrain.Scale*2/3, dimensions.Y*0.499)
-
-	conservative := seconds < 0
-	if conservative {
-		// Test a large margin around the dimensions
-		const margin = 200
-		dimensions.X += margin
-		dimensions.Y += margin
-		dx *= 0.25
-		dy *= 0.25
-	} else {
-		sweep := seconds * entity.Velocity.Float()
-		dimensions.X += sweep
-		position = position.AddScaled(normal, sweep*0.5)
-	}
-
-	// Make math easier later on by halving dimensions
-	const graceMargin = 0.9
-	dimensions = dimensions.Mul(0.5 * graceMargin)
-
-	// Not worth doing multiple terrain samples for small, slow moving entities
-	if dimensions.X <= terrain.Scale/5 && dimensions.Y <= terrain.Scale/5 {
-		return t.AtPos(entity.Position) > threshold
-	}
-
-	for l := -dimensions.X; l <= dimensions.X; l += dx {
-		for w := -dimensions.Y; w <= dimensions.Y; w += dy {
-			if t.AtPos(position.AddScaled(normal, l).AddScaled(tangent, w)) > threshold {
-				return true
-			}
-		}
-	}
-
-	return false
-}
-
-func (t *Terrain) Debug() {
-	fmt.Println("compressed terrain: chunks:", t.chunkCount)
-}
-
-// AtPos returns the height at a world position using bi-linear interpolation.
-func (t *Terrain) AtPos(pos world.Vec2f) byte {
-	pos = pos.Mul(1.0 / terrain.Scale)
-
-	cPos := pos.Ceil()
-	cx := uint(int(cPos.X) + Size/2)
-	cy := uint(int(cPos.Y) + Size/2)
-	if cx >= Size || cy >= Size {
-		return 0
-	}
-
-	fPos := pos.Floor()
-	fx := uint(int(fPos.X) + Size/2)
-	fy := uint(int(fPos.Y) + Size/2)
-	if fx >= Size || fy >= Size {
-		return 0
-	}
-
-	// Sample 2x2 grid
-	// 00 10
-	// 01 11
-	var c00, c10, c01, c11 byte
-
-	// Use faster version if all 2x2 pixels are in the same chunk
-	if fx/chunkSize == cx/chunkSize && fy/chunkSize == cy/chunkSize {
-		c := t.getChunk(fx, fy)
-		c00 = c.at(fx, fy)
-		c10 = c.at(cx, fy)
-		c01 = c.at(fx, cy)
-		c11 = c.at(cx, cy)
-	} else {
-		c00 = t.at(fx, fy)
-		c10 = t.at(cx, fy)
-		c01 = t.at(fx, cy)
-		c11 = t.at(cx, cy)
-	}
-
-	delta := pos.Sub(fPos)
-	return blerp(c00, c10, c01, c11, delta.X, delta.Y)
-}
-
-// Sculpt changes the terrain height at pos by an amount.
-func (t *Terrain) Sculpt(pos world.Vec2f, amount float32) {
-	pos = pos.Mul(1.0 / terrain.Scale)
-
-	cPos := pos.Ceil()
-	cx := uint(int(cPos.X) + Size/2)
-	cy := uint(int(cPos.Y) + Size/2)
-	if cx >= Size || cy >= Size {
-		return
-	}
-
-	fPos := pos.Floor()
-	fx := uint(int(fPos.X) + Size/2)
-	fy := uint(int(fPos.Y) + Size/2)
-	if fx >= Size || fy >= Size {
-		return
-	}
-
-	delta := pos.Sub(fPos)
-	amount *= 0.5
-
-	// Set 2x2 grid
-	// 00 10
-	// 01 11
-	t.set(fx, fy, clampToGrassByte(float32(t.at(fx, fy)+0b0011)+amount*(2-delta.X-delta.Y)))
-	t.set(cx, fy, clampToGrassByte(float32(t.at(cx, fy)+0b0011)+amount*(1+delta.X-delta.Y)))
-	t.set(fx, cy, clampToGrassByte(float32(t.at(fx, cy)+0b0011)+amount*(1-delta.X+delta.Y)))
-	t.set(cx, cy, clampToGrassByte(float32(t.at(cx, cy)+0b0011)+amount*(delta.X+delta.Y)))
-}
-
-// at gets the height of the terrain given x and y unsigned terrain coords.
-func (t *Terrain) at(x, y uint) byte {
-	return t.getChunk(x, y).at(x, y)
-}
-
-// at sets the height of the terrain given x, y unsigned terrain coords and the value to set it to.
-func (t *Terrain) set(x, y uint, value byte) {
-	t.getChunk(x, y).set(x, y, value)
-}
-
-// getChunk gets a chunk given its unsigned terrain coordinates.
-// TODO figure out how to get this inlined
-func (t *Terrain) getChunk(x, y uint) *chunk {
-	// Convert to chunk coordinates and elide bounds checks
-	x = (x / chunkSize) & (chunkCount - 1)
-	y = (y / chunkSize) & (chunkCount - 1)
-
-	// Use atomics/mutex to make sure chunk is generated
-	// Basically sync.Once for each chunk but with shared mutex
-	c := (*chunk)(atomic.LoadPointer((*unsafe.Pointer)(unsafe.Pointer(&t.chunks[x][y]))))
-	if c == nil {
-		return t.getChunkSlow(x, y)
-	}
-	return c
-}
-
-func (t *Terrain) getChunkSlow(x, y uint) *chunk {
-	chunkPtr := (*unsafe.Pointer)(unsafe.Pointer(&t.chunks[x][y]))
-
-	t.mutex.Lock()
-	defer t.mutex.Unlock()
-
-	// Load again to make sure its still nil after acquiring the lock
-	c := (*chunk)(atomic.LoadPointer(chunkPtr))
-	if c == nil {
-		// Generate chunk
-		c = generateChunk(t.generator, x-chunkCount/2, y-chunkCount/2, nil)
-		t.chunkCount++
-
-		// Store generated chunk
-		atomic.StorePointer(chunkPtr, unsafe.Pointer(c))
-	}
-
-	return c
-}
diff --git a/server/terrain/data.go b/server/terrain/data.go
new file mode 100644
index 00000000..0245a762
--- /dev/null
+++ b/server/terrain/data.go
@@ -0,0 +1,63 @@
+// SPDX-FileCopyrightText: 2021 Softbear, Inc.
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package terrain
+
+import (
+	"github.com/SoftbearStudios/mk48/server/world"
+	"sync"
+)
+
+/*
+	List of curated seeds/offsets:
+		1, 256, 256
+		46, 0, 128
+		48, 0, 64
+		56, -128, -128
+*/
+
+const (
+	// Seed default seed.
+	Seed = int64(56)
+	// OffsetX the default x offset from the origin in world space.
+	OffsetX = -128 * Scale
+	// OffsetY the default y offset from the origin in world space.
+	OffsetY = -128 * Scale
+)
+
+// Scale pixel width/height in meters.
+// Converts from world space to terrain space.
+const Scale = 25
+
+// Source generates heightmap data.
+type Source interface {
+	Generate(x, y, width, height int) []byte
+}
+
+// Data describes part of a heightmap.
+// It may be in a compressed format.
+type Data struct {
+	world.AABB
+	Data   []byte `json:"data"`   // Data is a possibly compressed terrain heightmap.
+	Stride int    `json:"stride"` // Stride is width of Data.
+	Length int    `json:"length"` // Length is uncompressed length of Data for faster reading.
+}
+
+var dataPool = sync.Pool{
+	New: func() interface{} {
+		return &Data{
+			Data: make([]byte, 0, 2048),
+		}
+	},
+}
+
+func NewData() *Data {
+	return dataPool.Get().(*Data)
+}
+
+func (data *Data) Pool() {
+	*data = Data{
+		Data: data.Data[:0],
+	}
+	dataPool.Put(data)
+}
diff --git a/server/terrain/height.go b/server/terrain/height.go
deleted file mode 100644
index 80761b7d..00000000
--- a/server/terrain/height.go
+++ /dev/null
@@ -1,12 +0,0 @@
-// SPDX-FileCopyrightText: 2021 Softbear, Inc.
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-package terrain
-
-const (
-	OceanLevel = 63
-	SandLevel  = OceanLevel + 10
-	GrassLevel = SandLevel + 50
-	RockLevel  = GrassLevel + 40
-	SnowLevel  = 255
-)
diff --git a/server/terrain/noise/noise.go b/server/terrain/noise/noise.go
index 4b12be50..405eaa31 100644
--- a/server/terrain/noise/noise.go
+++ b/server/terrain/noise/noise.go
@@ -16,8 +16,13 @@ const (
 
 // Generator generates a heightmap using perlin noise.
 type Generator struct {
-	small  *perlin.Perlin // for smaller details
-	large  *perlin.Perlin // for larger details
+	// Land/coast heightmap noise
+	landHi *perlin.Perlin // for smaller/higher frequency details
+	landLo *perlin.Perlin // for larger/lower frequency details
+
+	// Open water depth floor heightmap noise
+	waterLo *perlin.Perlin
+
 	offset world.Vec2f
 }
 
@@ -28,9 +33,10 @@ func NewDefault() *Generator {
 // New creates a new Generator with a seed.
 func New(seed int64, offsetX, offsetY float32) *Generator {
 	return &Generator{
-		small:  perlin.NewPerlin(1.5, 2.0, 4, seed),
-		large:  perlin.NewPerlin(2.5, 3.0, 4, seed+1),
-		offset: world.Vec2f{X: offsetX, Y: offsetY}.Mul(1.0 / terrain.Scale), // Scale to terrain space
+		landHi:  perlin.NewPerlin(1.5, 2.0, 4, seed),
+		landLo:  perlin.NewPerlin(2.5, 3.0, 4, seed+1),
+		waterLo: perlin.NewPerlin(2, 3.0, 3, seed+2),
+		offset:  world.Vec2f{X: offsetX, Y: offsetY}.Mul(1.0 / terrain.Scale), // Scale to terrain space
 	}
 }
 
@@ -47,16 +53,18 @@ func (g *Generator) Generate(px, py, width, height int) []byte {
 			x := (float64(i) + offX) * terrain.Scale
 			y := (float64(j) + offY) * terrain.Scale
 
-			h := g.small.Noise2D(x*frequency, y*frequency)*250 + terrain.OceanLevel - 50
+			h := g.landHi.Noise2D(x*frequency, y*frequency)*250 + terrain.SandLevel - 50
 
 			// Zone is very low frequency
-			zone := g.large.Noise2D(x*zoneFrequency, y*zoneFrequency)*2.0 + 0.4
+			zone := g.landLo.Noise2D(x*zoneFrequency, y*zoneFrequency)*2.0 + 0.4
 			if zone > 1 {
 				zone = 1
 			}
 			h *= zone
 
-			buf[i+j*width] = clampToByte(h)
+			depthFloor := clamp((g.waterLo.Noise2D(x*zoneFrequency, y*zoneFrequency)+0.3)*4, 0, 1) * terrain.SandLevel
+
+			buf[i+j*width] = clampToByte(max(h, depthFloor))
 		}
 	}
 
diff --git a/server/terrain/noise/util.go b/server/terrain/noise/util.go
index 5f15ee91..45f9b49f 100644
--- a/server/terrain/noise/util.go
+++ b/server/terrain/noise/util.go
@@ -3,6 +3,16 @@
 
 package noise
 
+func clamp(f, min, max float64) float64 {
+	if f < min {
+		return min
+	}
+	if f > max {
+		return max
+	}
+	return f
+}
+
 func clampToByte(f float64) byte {
 	if f < 0 {
 		return 0
@@ -12,3 +22,10 @@ func clampToByte(f float64) byte {
 	}
 	return byte(f)
 }
+
+func max(a, b float64) float64 {
+	if a > b {
+		return a
+	}
+	return b
+}
diff --git a/server/terrain/render.go b/server/terrain/render.go
index 91807709..1a44a298 100644
--- a/server/terrain/render.go
+++ b/server/terrain/render.go
@@ -10,15 +10,13 @@ import (
 type ColorVec [3]float32
 
 var colors = [...]ColorVec{
-	RGB(0, 50, 115),
-	RGB(0, 75, 130),
-	RGB(194, 178, 128),
-	RGB(90, 180, 30),
-	RGB(105, 110, 115),
-	Gray(220),
+	RGB(0, 50, 115),    // Deep water
+	RGB(0, 75, 130),    // Shallow water
+	RGB(194, 178, 128), // Sand
+	RGB(90, 180, 30),   // Grass
 }
 
-func Render(t Terrain, size int) image.Image {
+func (t *Terrain) Render(size int) image.Image {
 	o := float32(-size) * 0.5 * Scale
 	s := float32(size * Scale)
 	data := t.At(world.AABBFrom(o, o, s, s))
@@ -34,16 +32,15 @@ func Render(t Terrain, size int) image.Image {
 
 			h := raw[i+j*width]
 			switch {
-			case h <= OceanLevel:
-				c = colors[0].Lerp(colors[1], clamp(float32(h)/float32(OceanLevel)))
-			case h <= SandLevel:
+			case h < SandLevel:
+				// Water
+				c = colors[0].Lerp(colors[1], clamp(float32(h)/float32(SandLevel)))
+			case h < GrassLevel:
+				// Sand
 				c = colors[2]
-			case h <= GrassLevel:
-				c = colors[2].Lerp(colors[3], clamp(float32(h-SandLevel)*0.05))
-			case h <= RockLevel:
-				c = colors[3].Lerp(colors[4], clamp(float32(h-GrassLevel)*0.1))
 			default:
-				c = colors[4].Lerp(colors[5], clamp(float32(h-RockLevel)*0.07))
+				// Grass
+				c = colors[2].Lerp(colors[3], clamp(float32(h-GrassLevel)*0.05))
 			}
 
 			img.Set(i, j, c.Color())
diff --git a/server/terrain/render_cmd/main.go b/server/terrain/render_cmd/main.go
index 368416fa..07ec37e3 100644
--- a/server/terrain/render_cmd/main.go
+++ b/server/terrain/render_cmd/main.go
@@ -7,7 +7,6 @@ import (
 	"flag"
 	"fmt"
 	terrain "github.com/SoftbearStudios/mk48/server/terrain"
-	"github.com/SoftbearStudios/mk48/server/terrain/compressed"
 	"github.com/SoftbearStudios/mk48/server/terrain/noise"
 	"image/png"
 	"log"
@@ -37,8 +36,8 @@ func main() {
 
 func run() {
 	for i := int64(50); i < 200; i++ {
-		t := compressed.New(noise.New(i, 0, 0))
-		img := terrain.Render(t, compressed.Size)
+		t := terrain.New(noise.New(i, 0, 0))
+		img := t.Render(terrain.Size)
 
 		file, err := os.Create(fmt.Sprintf("out-%d.png", i))
 		if err != nil {
diff --git a/server/terrain/terrain.go b/server/terrain/terrain.go
index 4847b69d..ddc3fe3a 100644
--- a/server/terrain/terrain.go
+++ b/server/terrain/terrain.go
@@ -4,91 +4,300 @@
 package terrain
 
 import (
+	"fmt"
 	"github.com/SoftbearStudios/mk48/server/world"
+	"math/rand"
 	"sync"
+	"sync/atomic"
+	"time"
+	"unsafe"
 )
 
-/*
-	List of curated seeds/offsets:
-		1, 256, 256
-		46, 0, 128
-		48, 0, 64
-		56, -128, -128
-*/
-
 const (
-	// Seed default seed.
-	Seed = int64(56)
-	// OffsetX the default x offset from the origin in world space.
-	OffsetX = -128 * Scale
-	// OffsetY the default y offset from the origin in world space.
-	OffsetY = -128 * Scale
+	Size       = 2048
+	chunkCount = Size / chunkSize
 )
 
-// Scale pixel width/height in meters.
-// Converts from world space to terrain space.
-const Scale = 25
+// Coordinate types
+// World coordinates:            world.Entity.Position
+// Terrain coordinates:          World coordinates / terrain.Scale
+// Unsigned terrain coordinates: Terrain coordinates + Size / 2
+// Chunk coordinates:            Unsigned terrain coordinates / chunkSize
 
-// Source generates heightmap data.
-type Source interface {
-	Generate(x, y, width, height int) []byte
+// Terrain is a compressed implementation of terrain.Terrain.
+// It represents each terrain pixel with 4 bits of precision.
+type Terrain struct {
+	generator  Source
+	chunks     [chunkCount][chunkCount]*chunk
+	chunkCount int32
+	mutex      sync.Mutex
 }
 
-// Terrain stores the terrain of the game world.
-// It calls its Source to get more heightmap data.
-// All of its methods can be called concurrently (except Repair for now)
-type Terrain interface {
-	// At returns the heightmap data of a rect.
-	At(world.AABB) *Data
-	// AtPos returns the height at a point
-	AtPos(world.Vec2f) byte
-	// Clamp outputs the clamps the bounding box.
-	Clamp(world.AABB) world.AABB
-	// Collides tests if an entity collides with the Terrain.
-	// Negative numbers of seconds mean to do a conservative check (larger scan, more precise)
-	Collides(e *world.Entity, seconds float32) bool
-	// Decodes data from this terrain into a heightmap.
-	Decode(*Data) ([]byte, error)
-	// Modifies terrain at the position.
-	// Can't be called concurrently with any other terrain function.
-	Sculpt(pos world.Vec2f, change float32)
-	// Repairs the terrain a small amount towards its original state (prior to sculpting)
-	// Can't be called concurrently with any other terrain function.
-	Repair()
-	// Debug prints debug info to os.StdOut.
-	// Can't be called concurrently with any other terrain function.
-	Debug()
+// New creates a new Terrain from a source.
+func New(source Source) *Terrain {
+	return &Terrain{
+		generator: source,
+	}
 }
 
-func LandAtPos(t Terrain, pos world.Vec2f) bool {
-	// -6 is a kludge factor to make terrain math line up with client
-	return t.AtPos(pos) > OceanLevel-6
+// clampAABB returns a signed terrain coords aabb and unsigned terrain coords ux, uy, width, and height.
+func clampAABB(aabb world.AABB) (aabb2 world.AABB, ux, uy, width, height uint) {
+	p := aabb.Vec2f.Mul(1.0 / Scale).Sub(world.Vec2f{X: 1.0, Y: 1.0}).Floor()
+
+	// Signed terrain coords
+	x := maxInt(-Size/2, int(p.X))
+	y := maxInt(-Size/2, int(p.Y))
+
+	if x >= Size/2 || y >= Size/2 {
+		return
+	}
+
+	s := world.Vec2f{X: aabb.Width, Y: aabb.Height}.Mul(1.0 / Scale).Add(world.Vec2f{X: 2.0, Y: 2.0}).Ceil()
+	ux = uint(x + Size/2)
+	uy = uint(y + Size/2)
+	width = uint(minInt(Size/2-x, int(s.X)))
+	height = uint(minInt(Size/2-y, int(s.Y)))
+
+	aabb2 = world.AABB{
+		Vec2f: world.Vec2f{
+			X: float32(x) - 0.5,
+			Y: float32(y) - 0.5,
+		}.Mul(Scale),
+		Width:  float32(width) * Scale,
+		Height: float32(height) * Scale,
+	}
+
+	return
 }
 
-// Data describes part of a heightmap.
-// It may be in a compressed format.
-type Data struct {
-	world.AABB
-	Data   []byte `json:"data"`   // Data is a possibly compressed terrain heightmap.
-	Stride int    `json:"stride"` // Stride is width of Data.
-	Length int    `json:"length"` // Length is uncompressed length of Data for faster reading.
+// Clamp clamps a bounding box to what At will send.
+// It's useful for caching terrain data.
+func (t *Terrain) Clamp(aabb world.AABB) world.AABB {
+	clamped, _, _, _, _ := clampAABB(aabb)
+	return clamped
 }
 
-var dataPool = sync.Pool{
-	New: func() interface{} {
-		return &Data{
-			Data: make([]byte, 0, 2048),
+// At returns compressed terrain data at a given bounding box.
+func (t *Terrain) At(aabb world.AABB) *Data {
+	clamped, x, y, width, height := clampAABB(aabb)
+
+	data := NewData()
+	buffer := Buffer{
+		buf: data.Data,
+	}
+
+	for j := y; j < height+y; j++ {
+		for i := x; i < width+x; i++ {
+			buffer.writeByte(t.at(i, j))
 		}
-	},
+	}
+
+	data.AABB = clamped
+	data.Data = buffer.Buffer()
+	data.Stride = int(width)
+	data.Length = int(width * height)
+
+	return data
+}
+
+// Decode decompresses terrain data compressed with this terrain.
+func (t *Terrain) Decode(data *Data) (raw []byte, err error) {
+	var buf Buffer
+	buf.Reset(data.Data)
+	raw = make([]byte, data.Length)
+	_, err = buf.Read(raw)
+	return
 }
 
-func NewData() *Data {
-	return dataPool.Get().(*Data)
+// Repair reverts some of the terrain closer to its original generated state.
+func (t *Terrain) Repair() {
+	millis := time.Now().UnixNano() / int64(time.Millisecond/time.Nanosecond)
+	for ucx, chunks := range t.chunks {
+		for ucy, c := range chunks {
+			if c != nil && millis >= c.regen {
+				if c.regen != 0 { // Don't regen the first time
+					generateChunk(t.generator, uint(ucx)-Size/chunkSize/2, uint(ucy)-Size/chunkSize/2, c)
+				}
+				c.regen = millis + regenMillis + int64(rand.Intn(10000)) // add some randomness to avoid simultaneous regen
+			}
+		}
+	}
 }
 
-func (data *Data) Pool() {
-	*data = Data{
-		Data: data.Data[:0],
+// Collides returns if an entity collides with the terrain given a time step in seconds.
+func (t *Terrain) Collides(entity *world.Entity, seconds float32) bool {
+	data := entity.Data()
+	threshold := byte(SandLevel)
+	if entity.Altitude() > 0 {
+		threshold = GrassLevel
+	}
+
+	normal := entity.Direction.Vec2f()
+	tangent := normal.Rot90()
+
+	position := entity.Position
+	dimensions := world.Vec2f{X: data.Length, Y: data.Width}
+	dx := min(Scale*2/3, dimensions.X*0.499)
+	dy := min(Scale*2/3, dimensions.Y*0.499)
+
+	conservative := seconds < 0
+	if conservative {
+		// Test a large margin around the dimensions
+		const margin = 200
+		dimensions.X += margin
+		dimensions.Y += margin
+		dx *= 0.25
+		dy *= 0.25
+	} else {
+		sweep := seconds * entity.Velocity.Float()
+		dimensions.X += sweep
+		position = position.AddScaled(normal, sweep*0.5)
+	}
+
+	// Make math easier later on by halving dimensions
+	const graceMargin = 0.9
+	dimensions = dimensions.Mul(0.5 * graceMargin)
+
+	// Not worth doing multiple terrain samples for small, slow moving entities
+	if dimensions.X <= Scale/5 && dimensions.Y <= Scale/5 {
+		return t.AtPos(entity.Position) >= threshold
 	}
-	dataPool.Put(data)
+
+	for l := -dimensions.X; l <= dimensions.X; l += dx {
+		for w := -dimensions.Y; w <= dimensions.Y; w += dy {
+			if t.AtPos(position.AddScaled(normal, l).AddScaled(tangent, w)) >= threshold {
+				return true
+			}
+		}
+	}
+
+	return false
+}
+
+func (t *Terrain) Debug() {
+	fmt.Println("compressed terrain: chunks:", t.chunkCount)
+}
+
+// AtPos returns the height at a world position using bi-linear interpolation.
+func (t *Terrain) AtPos(pos world.Vec2f) byte {
+	pos = pos.Mul(1.0 / Scale)
+
+	cPos := pos.Ceil()
+	cx := uint(int(cPos.X) + Size/2)
+	cy := uint(int(cPos.Y) + Size/2)
+	if cx >= Size || cy >= Size {
+		return 0
+	}
+
+	fPos := pos.Floor()
+	fx := uint(int(fPos.X) + Size/2)
+	fy := uint(int(fPos.Y) + Size/2)
+	if fx >= Size || fy >= Size {
+		return 0
+	}
+
+	// Sample 2x2 grid
+	// 00 10
+	// 01 11
+	var c00, c10, c01, c11 byte
+
+	// Use faster version if all 2x2 pixels are in the same chunk
+	if fx/chunkSize == cx/chunkSize && fy/chunkSize == cy/chunkSize {
+		c := t.getChunk(fx, fy)
+		c00 = c.at(fx, fy)
+		c10 = c.at(cx, fy)
+		c01 = c.at(fx, cy)
+		c11 = c.at(cx, cy)
+	} else {
+		c00 = t.at(fx, fy)
+		c10 = t.at(cx, fy)
+		c01 = t.at(fx, cy)
+		c11 = t.at(cx, cy)
+	}
+
+	delta := pos.Sub(fPos)
+
+	// +6 to inconsistency between client and server
+	// uint16 to avoid overflow of byte
+	filtered := uint16(blerp(c00, c10, c01, c11, delta.X, delta.Y)) + 6
+	if filtered > 255 {
+		filtered = 255
+	}
+	return byte(filtered)
+}
+
+// Sculpt changes the terrain height at pos by an amount.
+func (t *Terrain) Sculpt(pos world.Vec2f, amount float32) {
+	pos = pos.Mul(1.0 / Scale)
+
+	cPos := pos.Ceil()
+	cx := uint(int(cPos.X) + Size/2)
+	cy := uint(int(cPos.Y) + Size/2)
+	if cx >= Size || cy >= Size {
+		return
+	}
+
+	fPos := pos.Floor()
+	fx := uint(int(fPos.X) + Size/2)
+	fy := uint(int(fPos.Y) + Size/2)
+	if fx >= Size || fy >= Size {
+		return
+	}
+
+	delta := pos.Sub(fPos)
+	amount *= 0.5
+
+	// Set 2x2 grid
+	// 00 10
+	// 01 11
+	t.set(fx, fy, clampToByte(float32(t.at(fx, fy)+0b0011)+amount*(2-delta.X-delta.Y)))
+	t.set(cx, fy, clampToByte(float32(t.at(cx, fy)+0b0011)+amount*(1+delta.X-delta.Y)))
+	t.set(fx, cy, clampToByte(float32(t.at(fx, cy)+0b0011)+amount*(1-delta.X+delta.Y)))
+	t.set(cx, cy, clampToByte(float32(t.at(cx, cy)+0b0011)+amount*(delta.X+delta.Y)))
+}
+
+// at gets the height of the terrain given x and y unsigned terrain coords.
+func (t *Terrain) at(x, y uint) byte {
+	return t.getChunk(x, y).at(x, y)
+}
+
+// at sets the height of the terrain given x, y unsigned terrain coords and the value to set it to.
+func (t *Terrain) set(x, y uint, value byte) {
+	t.getChunk(x, y).set(x, y, value)
+}
+
+// getChunk gets a chunk given its unsigned terrain coordinates.
+// TODO figure out how to get this inlined
+func (t *Terrain) getChunk(x, y uint) *chunk {
+	// Convert to chunk coordinates and elide bounds checks
+	x = (x / chunkSize) & (chunkCount - 1)
+	y = (y / chunkSize) & (chunkCount - 1)
+
+	// Use atomics/mutex to make sure chunk is generated
+	// Basically sync.Once for each chunk but with shared mutex
+	c := (*chunk)(atomic.LoadPointer((*unsafe.Pointer)(unsafe.Pointer(&t.chunks[x][y]))))
+	if c == nil {
+		return t.getChunkSlow(x, y)
+	}
+	return c
+}
+
+func (t *Terrain) getChunkSlow(x, y uint) *chunk {
+	chunkPtr := (*unsafe.Pointer)(unsafe.Pointer(&t.chunks[x][y]))
+
+	t.mutex.Lock()
+	defer t.mutex.Unlock()
+
+	// Load again to make sure its still nil after acquiring the lock
+	c := (*chunk)(atomic.LoadPointer(chunkPtr))
+	if c == nil {
+		// Generate chunk
+		c = generateChunk(t.generator, x-chunkCount/2, y-chunkCount/2, nil)
+		t.chunkCount++
+
+		// Store generated chunk
+		atomic.StorePointer(chunkPtr, unsafe.Pointer(c))
+	}
+
+	return c
 }
diff --git a/server/terrain/compressed/util.go b/server/terrain/util.go
similarity index 82%
rename from server/terrain/compressed/util.go
rename to server/terrain/util.go
index 9dd3f035..6461e4ed 100644
--- a/server/terrain/compressed/util.go
+++ b/server/terrain/util.go
@@ -1,19 +1,18 @@
 // SPDX-FileCopyrightText: 2021 Softbear, Inc.
 // SPDX-License-Identifier: AGPL-3.0-or-later
 
-package compressed
+package terrain
 
 import (
-	"github.com/SoftbearStudios/mk48/server/terrain"
 	"github.com/SoftbearStudios/mk48/server/world"
 )
 
-func clampToGrassByte(f float32) byte {
+func clampToByte(f float32) byte {
 	if f < 0 {
 		return 0
 	}
-	if f > terrain.GrassLevel {
-		return terrain.GrassLevel
+	if f > 255 {
+		return 255
 	}
 	return byte(f)
 }
diff --git a/server/world/collision.go b/server/world/collision.go
index 6352ec05..a71cabf3 100644
--- a/server/world/collision.go
+++ b/server/world/collision.go
@@ -7,10 +7,10 @@ import (
 	"github.com/chewxy/math32"
 )
 
-// Collider is anything that can collide with an Entity but can't be collided back such as terrain.
-// It also is required to not change while inside the world radius.
-type Collider interface {
+// Avoid import cycle by not importing terrain directly
+type Terrain interface {
 	Collides(entity *Entity, seconds float32) bool
+	AltitudeAt(Vec2f) float32
 }
 
 // Entities within this of eachother's altitudes can collide
diff --git a/server/world/entities.json b/server/world/entities.json
index 5caf3966..f465fc97 100644
--- a/server/world/entities.json
+++ b/server/world/entities.json
@@ -78,6 +78,7 @@
 		"level": 5,
 		"length": 113.3,
 		"width": 20.137,
+		"draft": 9.7,
 		"speed": 18.00556,
 		"sensors": {
 			"visual": {
@@ -182,6 +183,7 @@
 		"level": 4,
 		"length": 154,
 		"width": 20,
+		"draft": 9.3,
 		"speed": 17,
 		"stealth": 0.25,
 		"sensors": {
@@ -377,6 +379,7 @@
 		"level": 6,
 		"length": 241.6,
 		"width": 36,
+		"draft": 9.3,
 		"speed": 15.438478,
 		"sensors": {
 			"visual": {
@@ -525,6 +528,7 @@
 		"level": 4,
 		"length": 99,
 		"width": 16.5,
+		"draft": 6.4,
 		"speed": 8,
 		"sensors": {
 			"visual": {
@@ -556,6 +560,7 @@
 		"level": 6,
 		"length": 265.8,
 		"width": 42.5695,
+		"draft": 7,
 		"speed": 16.83333,
 		"sensors": {
 			"visual": {
@@ -720,6 +725,7 @@
 		"level": 1,
 		"length": 35,
 		"width": 6.35,
+		"draft": 1.45,
 		"speed": 15.9477,
 		"sensors": {
 			"visual": {
@@ -825,6 +831,7 @@
 		"level": 3,
 		"length": 114.8,
 		"width": 12,
+		"draft": 5.3,
 		"speed": 18.777,
 		"sensors": {
 			"visual": {
@@ -1052,6 +1059,7 @@
 		"level": 6,
 		"length": 115,
 		"width": 17.5,
+		"draft": 3.9,
 		"speed": 24.1789,
 		"stealth": 0.5,
 		"sensors": {
@@ -1219,6 +1227,7 @@
 		"level": 1,
 		"length": 18.85,
 		"width": 3.5,
+		"draft": 0.82,
 		"speed": 27.26557,
 		"sensors": {
 			"visual": {
@@ -1255,6 +1264,7 @@
 		"level": 3,
 		"length": 98.4,
 		"width": 8.2,
+		"draft": 8.5,
 		"speed": 8.7455,
 		"sensors": {
 			"visual": {
@@ -1325,6 +1335,7 @@
 		"level": 3,
 		"length": 52.8143,
 		"width": 13.6162,
+		"draft": 5,
 		"speed": 4,
 		"armaments": [
 			{
@@ -1416,6 +1427,7 @@
 		"level": 5,
 		"length": 163,
 		"width": 17.4,
+		"draft": 6.5,
 		"speed": 15.43334,
 		"stealth": 0.5,
 		"sensors": {
@@ -1590,6 +1602,7 @@
 		"level": 1,
 		"length": 25.4,
 		"width": 6.24,
+		"draft": 1.24,
 		"speed": 22.6,
 		"sensors": {
 			"visual": {
@@ -1686,6 +1699,7 @@
 		"level": 4,
 		"length": 169.1,
 		"width": 17.1,
+		"draft": 5.8,
 		"speed": 16.71945,
 		"sensors": {
 			"visual": {
@@ -1898,6 +1912,7 @@
 		"level": 3,
 		"length": 95.8,
 		"width": 10.8,
+		"draft": 2.38,
 		"speed": 8.5,
 		"sensors": {
 			"visual": {
@@ -1981,6 +1996,7 @@
 		"level": 2,
 		"length": 85.3,
 		"width": 7.9,
+		"draft": 2.4,
 		"speed": 18.52,
 		"sensors": {
 			"visual": {
@@ -2137,6 +2153,7 @@
 		"level": 7,
 		"length": 280.8,
 		"width": 36.93,
+		"draft": 10.97,
 		"speed": 14.404,
 		"sensors": {
 			"visual": {
@@ -2309,6 +2326,7 @@
 		"level": 7,
 		"length": 189,
 		"width": 34,
+		"draft": 7.7,
 		"speed": 14.66167,
 		"sensors": {
 			"visual": {
@@ -2334,12 +2352,6 @@
 				"positionSide": 8.0173,
 				"external": true
 			},
-			{
-				"type": "ka25",
-				"positionForward": -51.5997,
-				"positionSide": 0.800947,
-				"external": true
-			},
 			{
 				"type": "ka25",
 				"positionForward": -64.7966,
@@ -2472,6 +2484,7 @@
 		"level": 6,
 		"length": 170,
 		"width": 13,
+		"draft": 10.8,
 		"speed": 12.8611,
 		"sensors": {
 			"visual": {
@@ -2616,6 +2629,7 @@
 		"level": 1,
 		"length": 36.9,
 		"width": 5.5,
+		"draft": 1.25,
 		"speed": 16,
 		"sensors": {
 			"visual": {
@@ -2635,6 +2649,7 @@
 		"level": 3,
 		"length": 38.6,
 		"width": 7.64,
+		"draft": 1.73,
 		"speed": 21.6067,
 		"sensors": {
 			"visual": {
@@ -2719,6 +2734,7 @@
 		"level": 1,
 		"length": 23,
 		"width": 6.07,
+		"draft": 1.37,
 		"speed": 21.09,
 		"sensors": {
 			"visual": {
@@ -2771,6 +2787,7 @@
 		"level": 4,
 		"length": 76.71,
 		"width": 9.65,
+		"draft": 4.825,
 		"speed": 16.976667,
 		"sensors": {
 			"visual": {
@@ -2850,6 +2867,7 @@
 		"length": 179,
 		"width": 30.94,
 		"speed": 8.333333,
+		"draft": 11.6,
 		"sensors": {
 			"visual": {
 				"range": 1037
@@ -2868,6 +2886,7 @@
 		"level": 5,
 		"length": 180.3,
 		"width": 20.77676,
+		"draft": 6.28,
 		"speed": 16.59084,
 		"sensors": {
 			"visual": {
@@ -3104,6 +3123,7 @@
 		"level": 2,
 		"length": 67.1,
 		"width": 6.2,
+		"draft": 4.74,
 		"speed": 9.06,
 		"sensors": {
 			"visual": {
@@ -3183,6 +3203,7 @@
 		"level": 5,
 		"length": 72.7,
 		"width": 10.4,
+		"draft": 2.4,
 		"speed": 18.00556,
 		"stealth": 0.75,
 		"sensors": {
@@ -3354,6 +3375,7 @@
 		"level": 7,
 		"length": 130,
 		"width": 19.8046875,
+		"draft": 10,
 		"speed": 18.00556,
 		"sensors": {
 			"visual": {
@@ -3543,6 +3565,7 @@
 		"level": 2,
 		"length": 57,
 		"width": 21.15234375,
+		"draft": 1.6,
 		"speed": 28.29446,
 		"sensors": {
 			"visual": {
@@ -3683,6 +3706,7 @@
 		"level": 6,
 		"length": 190,
 		"width": 24.6,
+		"draft": 8.4,
 		"speed": 15.43333,
 		"stealth": 0.75,
 		"sensors": {
diff --git a/server/world/entity.go b/server/world/entity.go
index 6a09b0bb..4ed38c46 100644
--- a/server/world/entity.go
+++ b/server/world/entity.go
@@ -7,7 +7,10 @@ import (
 	"github.com/chewxy/math32"
 )
 
-const spawnProtection Ticks = 10 * TicksPerSecond
+const (
+	altitudeScale   float32 = 50 // 1 unit of altitude is this many meters
+	spawnProtection Ticks   = 10 * TicksPerSecond
+)
 
 // Entity is an object in the world such as a boat, torpedo, crate or oil platform.
 // Its size is 32 bytes for optimal efficiency.
@@ -26,7 +29,7 @@ type Entity struct {
 // Update updates all the variables of an Entity such as Position, Direction, ArmamentConsumption etc.
 // by an amount of time. It only modifies itself so each one can be processed by a different goroutine.
 // seconds cannot be > 1.0.
-func (entity *Entity) Update(ticks Ticks, worldRadius float32, collider Collider) (die bool) {
+func (entity *Entity) Update(ticks Ticks, worldRadius float32, terrain Terrain) (die bool) {
 	data := entity.Data()
 
 	if lifespan := data.Lifespan; lifespan != 0 {
@@ -49,6 +52,7 @@ func (entity *Entity) Update(ticks Ticks, worldRadius float32, collider Collider
 
 	// The following movement-related code must match the client's code
 	maxSpeed := data.Speed
+	repairElligible := true
 	seconds := ticks.Float()
 
 	if data.SubKind == EntitySubKindAircraft {
@@ -72,33 +76,14 @@ func (entity *Entity) Update(ticks Ticks, worldRadius float32, collider Collider
 		}
 	} else if data.SubKind == EntitySubKindSubmarine {
 		ext := &entity.Owner.ext
-		targetAltitude := clamp(ext.altitudeTarget(), -1, 0)
+		minAltitude := clamp((terrain.AltitudeAt(entity.Position)+data.Draft)/altitudeScale, -1, 0)
+		targetAltitude := clamp(ext.altitudeTarget(), minAltitude, 0)
 		altitudeSpeed := float32(0.25)
 		altitudeChange := clampMagnitude(targetAltitude-entity.Altitude(), altitudeSpeed*seconds)
 		ext.setAltitude(entity.Altitude() + altitudeChange)
 	}
 
 	boat := data.Kind == EntityKindBoat
-	if boat {
-		// Spawn protection
-		sp := entity.Owner.ext.getSpawnProtection()
-		toSubtract := ticks
-		// Avoid overflow of unsigned ticks
-		if toSubtract > sp {
-			toSubtract = sp
-		}
-		entity.Owner.ext.setSpawnProtection(sp - toSubtract)
-
-		turretsCopied := entity.updateTurretAim(ToAngle(seconds * (math32.Pi / 3)))
-		if len(entity.ArmamentConsumption()) > 0 {
-			// If turrets were already copied and the extension
-			// copies everything armaments don't need to be copied
-			armamentsCopied := entity.Owner.ext.copiesAll() && turretsCopied
-			entity.replenish(ticks, armamentsCopied)
-		}
-
-		entity.Repair(ticks)
-	}
 
 	// Shells that have been added so far can't turn
 	if data.SubKind != EntitySubKindShell && data.SubKind != EntitySubKindRocket {
@@ -126,26 +111,47 @@ func (entity *Entity) Update(ticks Ticks, worldRadius float32, collider Collider
 		}
 		entity.Position = entity.Position.AddScaled(entity.Direction.Vec2f(), seconds*entity.Velocity.Float())
 
-		// Test collisions with stationary objects
-		if collider != nil && collider.Collides(entity, seconds) {
-			if entity.Data().Kind != EntityKindBoat {
-				return true
-			}
-			entity.Velocity = entity.Velocity.ClampMagnitude(5 * MeterPerSecond)
-			if !(data.SubKind == EntitySubKindDredger || data.SubKind == EntitySubKindHovercraft) {
-				if entity.KillIn(ticks, 4*TicksPerSecond) {
-					if owner := entity.Owner; owner != nil {
-						owner.DeathReason = DeathReason{Type: DeathTypeTerrain}
-					}
+		// Test collisions with terrain
+
+		if terrain != nil {
+			velocityClamp := Velocity(math32.MaxInt16)
+			if terrain.Collides(entity, seconds) {
+				if !boat {
 					return true
 				}
+
+				repairElligible = false
+				velocityClamp = 5 * MeterPerSecond
+
+				if !(data.SubKind == EntitySubKindDredger || data.SubKind == EntitySubKindHovercraft) {
+					if entity.KillIn(ticks, 4*TicksPerSecond) {
+						if owner := entity.Owner; owner != nil {
+							owner.DeathReason = DeathReason{Type: DeathTypeTerrain}
+						}
+						return true
+					}
+				}
+			} else if boat {
+				belowKeel := entity.BelowKeel(terrain)
+
+				if belowKeel < 0 {
+					repairElligible = false
+
+					speedFactor := mapRanges(belowKeel, -5, 0, 0.6, 1, true)
+
+					//fmt.Println(terrain.AltitudeAt(entity.Position), belowKeel, speedFactor)
+					velocityClamp = Velocity(speedFactor * float32(maxSpeed))
+				}
 			}
+
+			entity.Velocity = entity.Velocity.ClampMagnitude(velocityClamp)
 		}
 	}
 
 	// Border (note: radius can shrink)
 	centerDist2 := entity.Position.LengthSquared()
 	if centerDist2 > square(worldRadius) {
+		repairElligible = false
 		dead := entity.KillIn(ticks, 1*TicksPerSecond)
 		entity.Velocity += ToVelocity(clampMagnitude(entity.Velocity.Float()-6*entity.Position.Dot(entity.Direction.Vec2f()), 15))
 		// Everything but boats is instantly killed by border
@@ -157,6 +163,29 @@ func (entity *Entity) Update(ticks Ticks, worldRadius float32, collider Collider
 		}
 	}
 
+	if boat {
+		// Spawn protection
+		sp := entity.Owner.ext.getSpawnProtection()
+		toSubtract := ticks
+		// Avoid overflow of unsigned ticks
+		if toSubtract > sp {
+			toSubtract = sp
+		}
+		entity.Owner.ext.setSpawnProtection(sp - toSubtract)
+
+		turretsCopied := entity.updateTurretAim(ToAngle(seconds * (math32.Pi / 3)))
+		if len(entity.ArmamentConsumption()) > 0 {
+			// If turrets were already copied and the extension
+			// copies everything armaments don't need to be copied
+			armamentsCopied := entity.Owner.ext.copiesAll() && turretsCopied
+			entity.replenish(ticks, armamentsCopied)
+		}
+
+		if repairElligible {
+			entity.Repair(ticks)
+		}
+	}
+
 	return false
 }
 
@@ -241,6 +270,11 @@ func (entity *Entity) Hash() float32 {
 	return float32(entity.EntityID&(hashSize-1)) * (1.0 / hashSize)
 }
 
+// Returns meters below keel
+func (entity *Entity) BelowKeel(terrain Terrain) float32 {
+	return entity.Altitude()*altitudeScale - entity.Data().Draft - terrain.AltitudeAt(entity.Position)
+}
+
 // Returns whether copied turret angles
 func (entity *Entity) updateTurretAim(amount Angle) bool {
 	turretsCopied := false
diff --git a/server/world/entity_data.go b/server/world/entity_data.go
index ac4329b5..a890d118 100644
--- a/server/world/entity_data.go
+++ b/server/world/entity_data.go
@@ -50,6 +50,7 @@ type (
 		Speed        Velocity      `json:"speed"`
 		Length       float32       `json:"length"`
 		Width        float32       `json:"width"`
+		Draft        float32       `json:"draft"`
 		Radius       float32       `json:"-"`
 		InvSize      float32       `json:"-"`
 		Damage       float32       `json:"damage"`       // health of ship, or damage dealt by weapon
diff --git a/server/world/util.go b/server/world/util.go
index 5460cb13..81f8cd6b 100644
--- a/server/world/util.go
+++ b/server/world/util.go
@@ -48,6 +48,17 @@ func square(a float32) float32 {
 	return a * a
 }
 
+func mapRanges(number, oldMin, oldMax, newMin, newMax float32, clampToRange bool) float32 {
+	oldRange := oldMax - oldMin
+	newRange := newMax - newMin
+	numberNormalized := (number - oldMin) / oldRange
+	mapped := newMin + numberNormalized*newRange
+	if clampToRange {
+		mapped = clamp(mapped, newMin, newMax)
+	}
+	return mapped
+}
+
 func unixMillis() int64 {
 	return time.Now().UnixNano() / (time.Millisecond.Nanoseconds() / time.Nanosecond.Nanoseconds())
 }
diff --git a/server/world/world.go b/server/world/world.go
index 4d4cc392..466d22d8 100644
--- a/server/world/world.go
+++ b/server/world/world.go
@@ -18,7 +18,7 @@ const (
 	RadiusClearance = 1.1
 
 	// PlayerSpace Target space (square meters) per each
-	PlayerSpace   = 300000
+	PlayerSpace   = 350000
 	CrateSpace    = 30000
 	ObstacleSpace = 1000000
 )


Test patch:
diff --git a/server/terrain/compressed/buffer_test.go b/server/terrain/buffer_test.go
similarity index 97%
rename from server/terrain/compressed/buffer_test.go
rename to server/terrain/buffer_test.go
index 95bf82b1..6f2881be 100644
--- a/server/terrain/compressed/buffer_test.go
+++ b/server/terrain/buffer_test.go
@@ -1,7 +1,7 @@
 // SPDX-FileCopyrightText: 2021 Softbear, Inc.
 // SPDX-License-Identifier: AGPL-3.0-or-later
 
-package compressed
+package terrain
 
 import (
 	"bytes"