diff --git a/src/MrGameEng.Simulation/WorldGen/Falloff.cs b/src/MrGameEng.Simulation/WorldGen/Falloff.cs
new file mode 100644
index 0000000..1c2e649
--- /dev/null
+++ b/src/MrGameEng.Simulation/WorldGen/Falloff.cs
@@ -0,0 +1,32 @@
+namespace MrGameEng.WorldGen;
+
+///
+/// Falloff masks that bias a heightmap toward water near the map edges, turning open noise into
+/// island-like continents. All methods are pure and deterministic.
+///
+public static class Falloff
+{
+ ///
+ /// Island mask for normalized coordinates (, ) in
+ /// [-1, 1] where (0,0) is the map centre. Returns 1 at the centre and falls toward 0 at
+ /// the edges; sets the coastline steepness (higher = sharper).
+ ///
+ public static float Island(float nx, float ny, float power = 3f)
+ {
+ // Chebyshev distance keeps the whole border ring low, so every edge becomes water.
+ var d = Math.Clamp(MathF.Max(MathF.Abs(nx), MathF.Abs(ny)), 0f, 1f);
+ return 1f - MathF.Pow(d, power);
+ }
+
+ ///
+ /// Island mask for a grid cell: maps (, ) on a
+ /// × grid into [-1, 1] and evaluates
+ /// .
+ ///
+ public static float EdgeFalloff(int x, int y, int width, int height, float power = 3f)
+ {
+ var nx = width <= 1 ? 0f : x / (float)(width - 1) * 2f - 1f;
+ var ny = height <= 1 ? 0f : y / (float)(height - 1) * 2f - 1f;
+ return Island(nx, ny, power);
+ }
+}
diff --git a/src/MrGameEng.Simulation/WorldGen/FractalNoise.cs b/src/MrGameEng.Simulation/WorldGen/FractalNoise.cs
new file mode 100644
index 0000000..d3c27d5
--- /dev/null
+++ b/src/MrGameEng.Simulation/WorldGen/FractalNoise.cs
@@ -0,0 +1,58 @@
+namespace MrGameEng.WorldGen;
+
+///
+/// Fractal Brownian motion over a source: sums several octaves of
+/// noise, each at a higher frequency () and lower amplitude
+/// (), to build natural detail at multiple scales. The sum is
+/// normalized back into [-1, 1] regardless of octave count. Immutable and deterministic.
+///
+public sealed class FractalNoise
+{
+ private readonly PerlinNoise _noise;
+
+ /// Number of noise layers summed (≥1). More octaves add finer detail.
+ public int Octaves { get; }
+
+ /// Base frequency applied to the first octave's sample coordinates.
+ public float Frequency { get; }
+
+ /// Amplitude multiplier between octaves (0..1, typically 0.5): lower is smoother.
+ public float Persistence { get; }
+
+ /// Frequency multiplier between octaves (typically 2): higher adds detail faster.
+ public float Lacunarity { get; }
+
+ /// Builds an fBm sampler over a Perlin field seeded by .
+ public FractalNoise(
+ int seed,
+ int octaves = 4,
+ float frequency = 1f,
+ float persistence = 0.5f,
+ float lacunarity = 2f
+ )
+ {
+ _noise = new PerlinNoise(seed);
+ Octaves = Math.Max(1, octaves);
+ Frequency = frequency;
+ Persistence = persistence;
+ Lacunarity = lacunarity;
+ }
+
+ /// Samples the summed octaves at (, ); result is in [-1, 1].
+ public float Sample(float x, float y)
+ {
+ var amplitude = 1f;
+ var frequency = Frequency;
+ var sum = 0f;
+ var totalAmplitude = 0f;
+ for (var i = 0; i < Octaves; i++)
+ {
+ sum += amplitude * _noise.Sample(x * frequency, y * frequency);
+ totalAmplitude += amplitude;
+ amplitude *= Persistence;
+ frequency *= Lacunarity;
+ }
+
+ return totalAmplitude > 0f ? sum / totalAmplitude : 0f;
+ }
+}
diff --git a/src/MrGameEng.Simulation/WorldGen/Heightmap.cs b/src/MrGameEng.Simulation/WorldGen/Heightmap.cs
new file mode 100644
index 0000000..ec0108d
--- /dev/null
+++ b/src/MrGameEng.Simulation/WorldGen/Heightmap.cs
@@ -0,0 +1,64 @@
+namespace MrGameEng.WorldGen;
+
+///
+/// A dense row-major grid of height values. Plain data with indexed access — fill it from a
+/// and read it from worldgen code. Values are arbitrary until
+/// rescales them into [0, 1].
+///
+public sealed class Heightmap
+{
+ /// Grid width in cells.
+ public int Width { get; }
+
+ /// Grid height in cells.
+ public int Height { get; }
+
+ /// Row-major values, length ×.
+ public float[] Values { get; }
+
+ /// Creates an all-zero map of the given size.
+ public Heightmap(int width, int height)
+ {
+ Width = width;
+ Height = height;
+ Values = new float[width * height];
+ }
+
+ /// Height at (, ).
+ public float this[int x, int y]
+ {
+ get => Values[y * Width + x];
+ set => Values[y * Width + x] = value;
+ }
+
+ /// Rescales all values into [0, 1] by min–max; a flat map becomes all zeros.
+ public void Normalize()
+ {
+ var min = float.MaxValue;
+ var max = float.MinValue;
+ foreach (var v in Values)
+ {
+ if (v < min)
+ {
+ min = v;
+ }
+
+ if (v > max)
+ {
+ max = v;
+ }
+ }
+
+ var range = max - min;
+ if (range <= float.Epsilon)
+ {
+ Array.Clear(Values);
+ return;
+ }
+
+ for (var i = 0; i < Values.Length; i++)
+ {
+ Values[i] = (Values[i] - min) / range;
+ }
+ }
+}
diff --git a/src/MrGameEng.Simulation/WorldGen/HeightmapGenerator.cs b/src/MrGameEng.Simulation/WorldGen/HeightmapGenerator.cs
new file mode 100644
index 0000000..aee05fa
--- /dev/null
+++ b/src/MrGameEng.Simulation/WorldGen/HeightmapGenerator.cs
@@ -0,0 +1,97 @@
+namespace MrGameEng.WorldGen;
+
+///
+/// Parameters for . Defaults give a single continent with a soft
+/// coastline; raise / for finer detail and
+/// for how strongly the edges are pushed underwater.
+///
+public readonly record struct HeightmapSettings
+{
+ /// Integer seed — the whole map derives from it.
+ public int Seed { get; init; }
+
+ /// Number of feature cycles across the map at the base octave (higher = smaller features).
+ public float Frequency { get; init; }
+
+ /// Number of fBm octaves (≥1).
+ public int Octaves { get; init; }
+
+ /// fBm amplitude falloff per octave (0..1).
+ public float Persistence { get; init; }
+
+ /// fBm frequency growth per octave (typically 2).
+ public float Lacunarity { get; init; }
+
+ /// Island falloff blend in [0, 1]: 0 disables it, 1 applies it fully.
+ public float IslandStrength { get; init; }
+
+ /// Coastline steepness of the island mask (higher = sharper); see .
+ public float IslandPower { get; init; }
+
+ /// Sensible defaults: one continent, 5 octaves, soft coastline.
+ public static HeightmapSettings Default =>
+ new()
+ {
+ Seed = 0,
+ Frequency = 3f,
+ Octaves = 5,
+ Persistence = 0.5f,
+ Lacunarity = 2f,
+ IslandStrength = 0.6f,
+ IslandPower = 3f,
+ };
+}
+
+///
+/// Turns into a normalized : fractal noise
+/// sampled per cell, optionally multiplied by an island falloff, then rescaled into [0, 1].
+/// Fully deterministic — the same settings always produce the same map, so a world can be rebuilt
+/// from its seed alone. Owns no world data.
+///
+public sealed class HeightmapGenerator
+{
+ private readonly HeightmapSettings _settings;
+ private readonly FractalNoise _noise;
+
+ /// Builds a generator for the given .
+ public HeightmapGenerator(HeightmapSettings settings)
+ {
+ _settings = settings;
+ _noise = new FractalNoise(
+ settings.Seed,
+ settings.Octaves,
+ frequency: 1f,
+ settings.Persistence,
+ settings.Lacunarity
+ );
+ }
+
+ /// Generates a × normalized heightmap.
+ public Heightmap Generate(int width, int height)
+ {
+ var map = new Heightmap(width, height);
+ var scaleX = width <= 1 ? 0f : _settings.Frequency / (width - 1);
+ var scaleY = height <= 1 ? 0f : _settings.Frequency / (height - 1);
+ var island = Math.Clamp(_settings.IslandStrength, 0f, 1f);
+
+ for (var y = 0; y < height; y++)
+ {
+ for (var x = 0; x < width; x++)
+ {
+ // fBm in [-1,1] → [0,1].
+ var n = _noise.Sample(x * scaleX, y * scaleY) * 0.5f + 0.5f;
+ if (island > 0f)
+ {
+ var mask = Falloff.EdgeFalloff(x, y, width, height, _settings.IslandPower);
+ // Blend the raw height with its masked self by the island strength.
+ n *= 1f - island + mask * island;
+ }
+
+ map[x, y] = n;
+ }
+ }
+
+ map.Normalize();
+ return map;
+ }
+}
diff --git a/src/MrGameEng.Simulation/WorldGen/PerlinNoise.cs b/src/MrGameEng.Simulation/WorldGen/PerlinNoise.cs
new file mode 100644
index 0000000..ba87f3c
--- /dev/null
+++ b/src/MrGameEng.Simulation/WorldGen/PerlinNoise.cs
@@ -0,0 +1,78 @@
+namespace MrGameEng.WorldGen;
+
+///
+/// Deterministic 2D Perlin (gradient) noise. Construct once per seed, then sample any number of
+/// points: is allocation-free and returns smooth values in [-1, 1]
+/// that vary continuously across the plane. The permutation table is built from the integer
+/// seed, so the same seed always yields the same field — the basis for reproducible world
+/// generation. is a pure read and safe to call concurrently.
+///
+public sealed class PerlinNoise
+{
+ private readonly int[] _perm = new int[512];
+
+ /// Builds the noise field deterministically from .
+ public PerlinNoise(int seed)
+ {
+ var p = new int[256];
+ for (var i = 0; i < 256; i++)
+ {
+ p[i] = i;
+ }
+
+ // Deterministic Fisher–Yates shuffle from the seed — never Random.Shared in simulation.
+ var random = new Random(seed);
+ for (var i = 255; i > 0; i--)
+ {
+ var j = random.Next(i + 1);
+ (p[i], p[j]) = (p[j], p[i]);
+ }
+
+ for (var i = 0; i < 512; i++)
+ {
+ _perm[i] = p[i & 255];
+ }
+ }
+
+ /// Samples the noise at (, ); result is in [-1, 1].
+ public float Sample(float x, float y)
+ {
+ var x0 = (int)MathF.Floor(x);
+ var y0 = (int)MathF.Floor(y);
+ var xi = x0 & 255;
+ var yi = y0 & 255;
+ var xf = x - x0;
+ var yf = y - y0;
+
+ var u = Fade(xf);
+ var v = Fade(yf);
+
+ var aa = _perm[_perm[xi] + yi];
+ var ab = _perm[_perm[xi] + yi + 1];
+ var ba = _perm[_perm[xi + 1] + yi];
+ var bb = _perm[_perm[xi + 1] + yi + 1];
+
+ var x1 = Lerp(Grad(aa, xf, yf), Grad(ba, xf - 1f, yf), u);
+ var x2 = Lerp(Grad(ab, xf, yf - 1f), Grad(bb, xf - 1f, yf - 1f), u);
+ return Lerp(x1, x2, v);
+ }
+
+ // Quintic ease curve (Perlin's improved fade): zero first and second derivatives at 0 and 1.
+ private static float Fade(float t) => t * t * t * (t * (t * 6f - 15f) + 10f);
+
+ private static float Lerp(float a, float b, float t) => a + t * (b - a);
+
+ // Dot product with one of 8 gradient directions chosen by the low bits of the hash.
+ private static float Grad(int hash, float x, float y) =>
+ (hash & 7) switch
+ {
+ 0 => x + y,
+ 1 => -x + y,
+ 2 => x - y,
+ 3 => -x - y,
+ 4 => x,
+ 5 => -x,
+ 6 => y,
+ _ => -y,
+ };
+}
diff --git a/tests/MrGameEng.Simulation.Tests/WorldGen/WorldGenTests.cs b/tests/MrGameEng.Simulation.Tests/WorldGen/WorldGenTests.cs
new file mode 100644
index 0000000..10c3fc9
--- /dev/null
+++ b/tests/MrGameEng.Simulation.Tests/WorldGen/WorldGenTests.cs
@@ -0,0 +1,124 @@
+using MrGameEng.WorldGen;
+using Xunit;
+
+namespace MrGameEng.WorldGen.Tests;
+
+public class WorldGenTests
+{
+ private static HeightmapSettings Settings(int seed) =>
+ HeightmapSettings.Default with
+ {
+ Seed = seed,
+ };
+
+ [Fact]
+ public void Generate_SameSeed_ProducesIdenticalMap()
+ {
+ var a = new HeightmapGenerator(Settings(1234)).Generate(64, 48);
+ var b = new HeightmapGenerator(Settings(1234)).Generate(64, 48);
+
+ Assert.Equal(a.Values, b.Values);
+ }
+
+ [Fact]
+ public void Generate_DifferentSeeds_ProduceDifferentMaps()
+ {
+ var a = new HeightmapGenerator(Settings(1)).Generate(64, 48);
+ var b = new HeightmapGenerator(Settings(2)).Generate(64, 48);
+
+ Assert.NotEqual(a.Values, b.Values);
+ }
+
+ [Fact]
+ public void Generate_AfterNormalize_ValuesSpanUnitRange()
+ {
+ var map = new HeightmapGenerator(Settings(7)).Generate(80, 60);
+
+ var min = float.MaxValue;
+ var max = float.MinValue;
+ foreach (var v in map.Values)
+ {
+ Assert.InRange(v, 0f, 1f);
+ min = MathF.Min(min, v);
+ max = MathF.Max(max, v);
+ }
+
+ Assert.Equal(0f, min, 3);
+ Assert.Equal(1f, max, 3);
+ }
+
+ [Fact]
+ public void Generate_WithIsland_EdgesAreLowerThanCentre()
+ {
+ var settings = HeightmapSettings.Default with { Seed = 42, IslandStrength = 1f };
+ var map = new HeightmapGenerator(settings).Generate(64, 64);
+
+ // Average the border ring vs. a centred block.
+ var edge = 0f;
+ var edgeCount = 0;
+ for (var x = 0; x < map.Width; x++)
+ {
+ edge += map[x, 0] + map[x, map.Height - 1];
+ edgeCount += 2;
+ }
+
+ var centre = 0f;
+ var centreCount = 0;
+ for (var y = map.Height / 2 - 4; y < map.Height / 2 + 4; y++)
+ {
+ for (var x = map.Width / 2 - 4; x < map.Width / 2 + 4; x++)
+ {
+ centre += map[x, y];
+ centreCount++;
+ }
+ }
+
+ Assert.True(
+ edge / edgeCount < centre / centreCount,
+ "island edges should sit below centre"
+ );
+ }
+
+ [Fact]
+ public void PerlinSample_StaysWithinUnitRange()
+ {
+ var noise = new PerlinNoise(99);
+ for (var i = 0; i < 2000; i++)
+ {
+ var v = noise.Sample(i * 0.37f, i * 0.19f);
+ Assert.InRange(v, -1f, 1f);
+ }
+ }
+
+ [Fact]
+ public void PerlinSample_IsContinuousBetweenNeighbours()
+ {
+ var noise = new PerlinNoise(5);
+ var previous = noise.Sample(0f, 0f);
+ for (var i = 1; i < 500; i++)
+ {
+ var current = noise.Sample(i * 0.01f, 0f);
+ Assert.True(
+ MathF.Abs(current - previous) < 0.2f,
+ "noise jumped between adjacent samples"
+ );
+ previous = current;
+ }
+ }
+
+ [Fact]
+ public void PerlinSample_AtIntegerLattice_IsZero()
+ {
+ var noise = new PerlinNoise(3);
+ Assert.Equal(0f, noise.Sample(4f, 7f), 5);
+ }
+
+ [Fact]
+ public void IslandFalloff_CentreIsHigherThanEdge()
+ {
+ Assert.True(Falloff.Island(0f, 0f) > Falloff.Island(1f, 0f));
+ Assert.True(Falloff.Island(0f, 0f) > Falloff.Island(0f, 1f));
+ Assert.Equal(1f, Falloff.Island(0f, 0f), 5);
+ Assert.Equal(0f, Falloff.Island(1f, 1f), 5);
+ }
+}