Make world wrap seamlessly in both axes

Tileable Perlin noise: each octave wraps its integer grid coordinates
with modulo at the octave's frequency, so gradients at opposite edges
match and the noise field is continuous across the boundary.

Coarse elevation grid interpolation wraps instead of padding boundary
cells. Rivers can flow across world edges. All coordinate access
(get_tile, is_passable, get_viewport) wraps via modulo. Movement,
spawn search, nearby-player detection, and viewport relative positions
all handle the toroidal topology.
This commit is contained in:
Jared Miller 2026-02-07 13:50:06 -05:00
parent 9cd9c6f790
commit 8934397b1e
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
6 changed files with 112 additions and 80 deletions

View file

@ -34,9 +34,19 @@ async def cmd_look(player: Player, args: str) -> None:
if other.name == player.name:
continue
# Calculate relative position
rel_x = other.x - player.x + center_x
rel_y = other.y - player.y + center_y
# Calculate relative position (shortest path wrapping)
dx = other.x - player.x
dy = other.y - player.y
if dx > world.width // 2:
dx -= world.width
elif dx < -(world.width // 2):
dx += world.width
if dy > world.height // 2:
dy -= world.height
elif dy < -(world.height // 2):
dy += world.height
rel_x = dx + center_x
rel_y = dy + center_y
# Check if within viewport bounds
if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT:

View file

@ -65,8 +65,7 @@ async def move_player(player: Player, dx: int, dy: int, direction_name: str) ->
dy: Y delta
direction_name: Full name of the direction for messages
"""
target_x = player.x + dx
target_y = player.y + dy
target_x, target_y = world.wrap(player.x + dx, player.y + dy)
# Check if the target is passable
if not world.is_passable(target_x, target_y):
@ -111,8 +110,12 @@ async def send_nearby_message(player: Player, x: int, y: int, message: str) -> N
if other.name == player.name:
continue
# Check if other player is within viewport range
if abs(other.x - x) <= viewport_range and abs(other.y - y) <= viewport_range:
# Check if other player is within viewport range (wrapping)
dx_dist = abs(other.x - x)
dy_dist = abs(other.y - y)
dx_dist = min(dx_dist, world.width - dx_dist)
dy_dist = min(dy_dist, world.height - dy_dist)
if dx_dist <= viewport_range and dy_dist <= viewport_range:
other.writer.write(message)
await other.writer.drain()

View file

@ -38,7 +38,7 @@ def find_passable_start(world: World, start_x: int, start_y: int) -> tuple[int,
if world.is_passable(start_x, start_y):
return start_x, start_y
# Spiral outward from the starting position
# Spiral outward from the starting position (wrapping)
for radius in range(1, 100):
for dx in range(-radius, radius + 1):
for dy in range(-radius, radius + 1):
@ -46,12 +46,7 @@ def find_passable_start(world: World, start_x: int, start_y: int) -> tuple[int,
if abs(dx) != radius and abs(dy) != radius:
continue
x = start_x + dx
y = start_y + dy
# Check bounds
if not (0 <= x < world.width and 0 <= y < world.height):
continue
x, y = world.wrap(start_x + dx, start_y + dy)
if world.is_passable(x, y):
return x, y

View file

@ -12,13 +12,18 @@ def _make_perm(seed: int) -> list[int]:
return p + p
def _noise(perm: list[int], x: float, y: float) -> float:
"""2D Perlin noise, fully inlined for speed."""
def _noise(perm: list[int], x: float, y: float, px: int, py: int) -> float:
"""2D tileable Perlin noise.
Wraps seamlessly with period px along x and py along y.
"""
# unit grid cell
fx = math.floor(x)
fy = math.floor(y)
xi = int(fx) & 255
yi = int(fy) & 255
xi = int(fx) % px
yi = int(fy) % py
xi1 = (xi + 1) % px
yi1 = (yi + 1) % py
# relative coordinates within cell
xf = x - fx
@ -28,11 +33,11 @@ def _noise(perm: list[int], x: float, y: float) -> float:
u = xf * xf * xf * (xf * (xf * 6 - 15) + 10)
v = yf * yf * yf * (yf * (yf * 6 - 15) + 10)
# hash coordinates of 4 corners
# hash coordinates of 4 corners (wrapping)
aa = perm[perm[xi] + yi]
ab = perm[perm[xi] + yi + 1]
ba = perm[perm[xi + 1] + yi]
bb = perm[perm[xi + 1] + yi + 1]
ab = perm[perm[xi] + yi1]
ba = perm[perm[xi1] + yi]
bb = perm[perm[xi1] + yi1]
# grad + lerp inlined for all 4 corners
xf1 = xf - 1
@ -74,19 +79,20 @@ class World:
self.terrain = self._generate_terrain()
def _generate_elevation(self) -> list[list[float]]:
"""Generate elevation map using fractal noise.
"""Generate tileable elevation map using fractal noise.
Computes at 1/4 resolution and interpolates up for speed.
Tiles seamlessly in both axes so the world can wrap.
"""
w = self.width
h = self.height
perm = _make_perm(self.seed)
noise = _noise
# generate at 1/4 resolution
# generate at 1/4 resolution (no boundary padding, we wrap instead)
step = 4
cw = w // step + 2 # +2 for interpolation boundary
ch = h // step + 2
cw = w // step
ch = h // step
coarse = [[0.0] * cw for _ in range(ch)]
min_val = float("inf")
@ -95,14 +101,15 @@ class World:
for cy in range(ch):
row = coarse[cy]
for cx in range(cw):
# map coarse coords back to world space
# map coarse coords back to world space [0, 1)
wx = cx * step / w
wy = cy * step / h
# each octave tiles at its own frequency
value = (
noise(perm, wx * 4, wy * 4)
+ noise(perm, wx * 8, wy * 8) * 0.5
+ noise(perm, wx * 16, wy * 16) * 0.25
noise(perm, wx * 4, wy * 4, 4, 4)
+ noise(perm, wx * 8, wy * 8, 8, 8) * 0.5
+ noise(perm, wx * 16, wy * 16, 16, 16) * 0.25
)
row[cx] = value
if value < min_val:
@ -119,15 +126,16 @@ class World:
for cx in range(cw):
row[cx] = (row[cx] - min_val) * inv_range
# bilinear interpolation to full resolution
# bilinear interpolation to full resolution (wrapping at edges)
inv_step = 1.0 / step
elevation = [[0.0] * w for _ in range(h)]
for y in range(h):
cy_f = y * inv_step
cy0 = int(cy_f)
cy1 = min(cy0 + 1, ch - 1)
fy = cy_f - cy0
cy_floor = int(cy_f)
cy0 = cy_floor % ch
cy1 = (cy0 + 1) % ch
fy = cy_f - cy_floor
fy_inv = 1.0 - fy
row_0 = coarse[cy0]
row_1 = coarse[cy1]
@ -135,9 +143,10 @@ class World:
for x in range(w):
cx_f = x * inv_step
cx0 = int(cx_f)
cx1 = min(cx0 + 1, cw - 1)
fx = cx_f - cx0
cx_floor = int(cx_f)
cx0 = cx_floor % cw
cx1 = (cx0 + 1) % cw
fx = cx_f - cx_floor
# bilinear interpolation
out_row[x] = (
@ -187,47 +196,44 @@ class World:
) -> None:
"""Generate rivers from mountains to water bodies."""
rng = random.Random(self.seed)
w = self.width
h = self.height
# find some high-elevation starting points
num_rivers = max(5, (self.width * self.height) // 50000)
num_rivers = max(5, (w * h) // 50000)
for _ in range(num_rivers):
# pick random high elevation point
x = rng.randint(0, self.width - 1)
y = rng.randint(0, self.height - 1)
x = rng.randint(0, w - 1)
y = rng.randint(0, h - 1)
if elevation[y][x] < 0.6:
continue
# trace downhill
# trace downhill (wrapping at edges)
visited = set()
while True:
if (x, y) in visited or len(visited) > 200:
break
visited.add((x, y))
# if we hit water or edge, stop
# if we hit water, stop
if terrain[y][x] == self.TERRAIN_CHARS["water"]:
break
if x <= 0 or x >= self.width - 1 or y <= 0 or y >= self.height - 1:
break
# carve river
if elevation[y][x] < 0.75: # don't carve through mountains
terrain[y][x] = self.TERRAIN_CHARS["water"]
# find steepest descent to neighbor
# find steepest descent to neighbor (wrapping)
current_elevation = elevation[y][x]
best_x, best_y = x, y
best_elevation = current_elevation
for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
nx, ny = x + dx, y + dy
if (
0 <= nx < self.width
and 0 <= ny < self.height
and elevation[ny][nx] < best_elevation
):
nx = (x + dx) % w
ny = (y + dy) % h
if elevation[ny][nx] < best_elevation:
best_elevation = elevation[ny][nx]
best_x, best_y = nx, ny
@ -243,39 +249,36 @@ class World:
self._generate_rivers(terrain, elevation)
return terrain
def wrap(self, x: int, y: int) -> tuple[int, int]:
"""Wrap coordinates to stay within world bounds."""
return x % self.width, y % self.height
def get_tile(self, x: int, y: int) -> str:
"""Return terrain character at position."""
if not (0 <= x < self.width and 0 <= y < self.height):
raise ValueError(f"Coordinates ({x}, {y}) out of bounds")
return self.terrain[y][x]
"""Return terrain character at position (wrapping)."""
wx, wy = self.wrap(x, y)
return self.terrain[wy][wx]
def is_passable(self, x: int, y: int) -> bool:
"""Check if position is passable."""
"""Check if position is passable (wrapping)."""
tile = self.get_tile(x, y)
return tile not in {self.TERRAIN_CHARS["mountain"], self.TERRAIN_CHARS["water"]}
def get_viewport(
self, cx: int, cy: int, width: int, height: int
) -> list[list[str]]:
"""Return 2D slice of terrain centered on (cx, cy)."""
"""Return 2D slice of terrain centered on (cx, cy), wrapping at edges."""
viewport = []
# calculate bounds
half_width = width // 2
half_height = height // 2
start_y = cy - half_height
end_y = start_y + height
start_x = cx - half_width
end_x = start_x + width
for y in range(start_y, end_y):
for dy in range(height):
row = []
for x in range(start_x, end_x):
# clamp to world bounds
wx = max(0, min(x, self.width - 1))
wy = max(0, min(y, self.height - 1))
for dx in range(width):
wx, wy = self.wrap(start_x + dx, start_y + dy)
row.append(self.terrain[wy][wx])
viewport.append(row)

View file

@ -25,7 +25,10 @@ def mock_reader():
@pytest.fixture
def mock_world():
world = MagicMock()
world.width = 100
world.height = 100
world.is_passable = MagicMock(return_value=True)
world.wrap = MagicMock(side_effect=lambda x, y: (x % 100, y % 100))
# Create a 21x11 viewport filled with "."
viewport = [["." for _ in range(21)] for _ in range(11)]
world.get_viewport = MagicMock(return_value=viewport)

View file

@ -1,5 +1,3 @@
import pytest
from mudlib.world.terrain import World
@ -36,12 +34,11 @@ def test_world_dimensions():
world.get_tile(0, 0)
world.get_tile(99, 49)
# should raise for out of bounds
with pytest.raises((IndexError, ValueError)):
world.get_tile(100, 0)
with pytest.raises((IndexError, ValueError)):
world.get_tile(0, 50)
# coordinates wrap instead of raising
assert world.get_tile(100, 0) == world.get_tile(0, 0)
assert world.get_tile(0, 50) == world.get_tile(0, 0)
assert world.get_tile(-1, 0) == world.get_tile(99, 0)
assert world.get_tile(0, -1) == world.get_tile(0, 49)
def test_get_tile_returns_valid_terrain():
@ -129,19 +126,23 @@ def test_get_viewport_centers_correctly():
assert center_tile == world.get_tile(50, 50)
def test_get_viewport_handles_edges():
"""Viewport handles world boundaries correctly."""
def test_get_viewport_wraps_at_edges():
"""Viewport wraps around world boundaries."""
world = World(seed=42, width=100, height=100)
# near top-left corner
viewport = world.get_viewport(2, 2, width=5, height=5)
# near top-left corner - viewport should wrap to bottom/right
viewport = world.get_viewport(0, 0, width=5, height=5)
assert len(viewport) == 5
assert len(viewport[0]) == 5
# top-left of viewport wraps to bottom-right of world
assert viewport[0][0] == world.get_tile(98, 98)
# near bottom-right corner
viewport = world.get_viewport(97, 97, width=5, height=5)
# near bottom-right corner - viewport should wrap to top/left
viewport = world.get_viewport(99, 99, width=5, height=5)
assert len(viewport) == 5
assert len(viewport[0]) == 5
# bottom-right of viewport wraps to top-left of world
assert viewport[4][4] == world.get_tile(1, 1)
def test_terrain_distribution_reasonable():
@ -178,6 +179,23 @@ def test_rivers_exist():
assert water_count > 0
def test_terrain_tiles_seamlessly():
"""Terrain at opposite edges matches for seamless wrapping."""
world = World(seed=42, width=100, height=100)
# check that a strip along the right edge is continuous with the left edge
# by verifying the elevation-derived terrain doesn't have a hard seam.
# the noise is tileable, so adjacent tiles across the boundary should be
# from the same smooth noise field (not identical, but not a cliff).
# we verify the wrap helper itself works correctly:
for y in range(100):
assert world.get_tile(-1, y) == world.get_tile(99, y)
assert world.get_tile(100, y) == world.get_tile(0, y)
for x in range(100):
assert world.get_tile(x, -1) == world.get_tile(x, 99)
assert world.get_tile(x, 100) == world.get_tile(x, 0)
def test_large_world_generation():
"""Can generate a 1000x1000 world without errors."""
world = World(seed=42, width=1000, height=1000)