Skip to content

feat: generate the world map on the server - #13

Closed
JJiang28 wants to merge 1 commit into
mainfrom
feat/server-generated-terrain
Closed

feat: generate the world map on the server#13
JJiang28 wants to merge 1 commit into
mainfrom
feat/server-generated-terrain

Conversation

@JJiang28

@JJiang28 JJiang28 commented Aug 3, 2026

Copy link
Copy Markdown

Summary

  • Adds internal/world, a dependency-free procedural generator producing three orthogonal planes (ground, relief, vegetation) plus rivers and special resources, deterministic from constants.WorldSeed. The world was previously a featureless 75×75 grid with no terrain of any kind.
  • Ships the map via a new MapService.GetTerrain, packed one byte per tile — a few KB for the whole map, so it is neither paged nor filtered by vision.
  • Makes settlement placement terrain-aware: canPlace now rejects footprints on water, peaks and ice, and player starts are chosen by scanning and scoring the map rather than drawing random empty blocks.

Notes for reviewers

Two decisions that are load-bearing and easy to undo by accident:

  • Thresholds are quantiles of the elevation field, not fixed cutoffs. Land is exactly landFraction regardless of what the octaves do, so retuning noise can't silently flood or drown the world.
  • Noise is sampled at hexPoint(), not at (col, row). Columns sit 1.5 apart and rows only ~0.866, so grid-space sampling stretches every coastline and mountain range ~1.73× horizontally once drawn.

FindEmptyCityBlock was rewritten rather than tweaked. Towns are seeded first and take the good land, so whatever the SQL query leaves free is disproportionately the water and mountain the seeder just rejected — drawing from it seated the test player inside a mountain range. It now scans in memory and scores candidates on fertility, fresh water and coastline.

Terrain-aware rejection halved the towns surviving seeding, so townMinSpacing drops 5 → 4 to hold density roughly where it was. That is a gameplay-density change, not just a visual one.

Test plan

  • go build ./..., go vet ./... and gofmt -l internal/ cmd/ clean
  • buf lint clean; buf generate reproduces the committed output exactly
  • Boots against a local Postgres and serves: 99 towns seeded, every one on buildable ground, no "no habitable start block" warnings
  • Rebased onto main after feat: troops & armies (existence phase, no combat) #12 (troops & armies); both coexist — army actors spawn and terrain generates in the same boot
  • Generation is deterministic (identical planes on regenerate) and every river link is reciprocal across the shared edge
  • Distribution is stable across seeds (land exactly 70%, hills 18.2%, mountains 5.6%)
  • Verified in a browser against this backend: terrain renders, capital lands on coastal grassland beside a river

The world was a featureless 75x75 grid — no terrain, no biomes, no
tiles table. Every tile the client drew was grass or fog, and
settlements were scattered by Poisson-disk sampling with no idea what
the ground underneath them was.

Add internal/world: a dependency-free generator producing three
orthogonal planes (ground, relief, vegetation) plus rivers and special
resources, deterministic from constants.WorldSeed. Terrain is
regenerated on every boot rather than persisted, so the map survives
the reset that wipes everything else. MapService.GetTerrain ships the
planes packed one byte per tile — a few kilobytes for the whole map,
so it is neither paged nor filtered by vision.

Three decisions worth recording:

Planes stay orthogonal rather than collapsing into one biome enum.
Collapsed, forest-on-tundra and forest-on-plains become separate
values each needing their own art; kept apart, a feature composites
over any ground.

Thresholds are quantiles of the elevation field rather than fixed
cutoffs, so retuning the noise cannot accidentally flood or drown the
world — land is exactly landFraction whatever the octaves do.

Noise is sampled at hexPoint(), not at (col, row). Columns sit 1.5
apart and rows only ~0.866, so sampling in grid space stretches every
coastline and mountain range by about 1.73x horizontally once drawn.

Placement is now terrain-aware. canPlace rejects footprints on water,
peaks and ice, which halved the towns surviving seeding, so
townMinSpacing drops 5 -> 4 to hold density roughly where it was.

FindEmptyCityBlock no longer draws random empty blocks. Towns are
seeded first and take the good land, so whatever the query leaves free
is disproportionately the water and mountain the seeder rejected —
players drawn that way reliably land inside a mountain range. It now
scans the map in memory, scores every candidate on fertility, fresh
water and coastline, and picks among the best 15% so consecutive
registrations do not share a tile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant