diff --git a/client/src/GameLogic/ArenaBuilders.cs b/client/src/GameLogic/ArenaBuilders.cs
index f952d49..a4bd7d0 100644
--- a/client/src/GameLogic/ArenaBuilders.cs
+++ b/client/src/GameLogic/ArenaBuilders.cs
@@ -19,11 +19,12 @@ public sealed record ArenaLayout(
GroundTheme GroundTheme);
/// Builds one built-in arena. A content slice implements this per map (a full themed layout
-/// of ~2x size with eight spawns and powerup pads); the stubs here return a small valid arena so the
-/// game runs and tests pass until then.
+/// of ~2x size with eight spawns and powerup pads). The places the spawns
+/// (seeded random, min-distance separated) and never touches the terrain, so net peers passing the
+/// shared match seed derive identical layouts.
public interface IArenaBuilder
{
- ArenaLayout Build();
+ ArenaLayout Build(int seed);
}
/// Registry of the code-built arenas, keyed by arena id name (matching the Presentation
@@ -69,15 +70,15 @@ public static bool TryGet(string arenaId, out IArenaBuilder builder)
// ── Hand-authored themed arenas ───────────────────────────────────────────────────────────────
// Each map is a ~76x46 steel-ringed field authored with loops and constants only — no Random, Guid, or
-// time — so host and guest calling Build() independently get byte-identical layouts (net-synced).
-// Spawns come from SpawnTable (eight symmetric, well-separated starts, each nudged to open floor); the
+// time — so host and guest calling Build(seed) independently get byte-identical layouts (net-synced).
+// Spawns come from SpawnTable (eight seeded-random, min-distance-separated starts on open floor); the
// shared ArenaAuthor.Assemble stitches materials + spawns + pickups into an ArenaLayout.
/// Forest Ambush: open clearings dotted with Mountain hills and bush copses that let an
/// ambusher lie hidden between the trees.
public sealed class ForestArena : IArenaBuilder
{
- public ArenaLayout Build()
+ public ArenaLayout Build(int seed)
{
var (materials, bushes) = ArenaAuthor.Field();
@@ -102,7 +103,7 @@ public ArenaLayout Build()
(PowerupKind.Missile, 6, 6),
};
- return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.Jungle, (2, 2), (73, 43));
+ return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.Jungle, seed);
}
}
@@ -110,7 +111,7 @@ public ArenaLayout Build()
/// shots fly over it but destroys any tank that drives onto it — the bridges are the only safe crossings.
public sealed class VolcanoArena : IArenaBuilder
{
- public ArenaLayout Build()
+ public ArenaLayout Build(int seed)
{
var (materials, bushes) = ArenaAuthor.Field();
@@ -151,7 +152,7 @@ public ArenaLayout Build()
(PowerupKind.SpeedBoost, 70, 22),
};
- return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.Volcano, (2, 2), (73, 43));
+ return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.Volcano, seed);
}
}
@@ -162,7 +163,7 @@ public sealed class CityArena : IArenaBuilder
private const int Period = 12; // one city block (9 wide) plus its 3-wide road
private const int RoadWidth = 3;
- public ArenaLayout Build()
+ public ArenaLayout Build(int seed)
{
var (materials, bushes) = ArenaAuthor.Field();
@@ -187,7 +188,7 @@ public ArenaLayout Build()
(PowerupKind.Telephone, 1, 23),
};
- return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.ParkingLot, (1, 1), (73, 43));
+ return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.ParkingLot, seed);
}
}
@@ -195,7 +196,7 @@ public ArenaLayout Build()
/// ponds, each crossed by a Bridge so no pocket is walled off. Long sightlines between the cover.
public sealed class FrozenArena : IArenaBuilder
{
- public ArenaLayout Build()
+ public ArenaLayout Build(int seed)
{
var (materials, bushes) = ArenaAuthor.Field();
@@ -228,7 +229,7 @@ public ArenaLayout Build()
(PowerupKind.Missile, 14, 14),
};
- return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.Sand, (2, 2), (73, 43));
+ return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.Sand, seed);
}
}
@@ -236,7 +237,7 @@ public ArenaLayout Build()
/// raised central mesa (elevation layer 1) reached only by ramps on its four sides.
public sealed class CanyonArena : IArenaBuilder
{
- public ArenaLayout Build()
+ public ArenaLayout Build(int seed)
{
var (materials, bushes) = ArenaAuthor.Field();
var layers = new int[ArenaAuthor.Width, ArenaAuthor.Height];
@@ -291,7 +292,7 @@ public ArenaLayout Build()
(PowerupKind.RapidFire, 69, 38),
};
- return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.Mars, (2, 2), (73, 43), layers, ramps);
+ return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.Mars, seed, layers, ramps);
}
}
@@ -304,7 +305,7 @@ public ArenaLayout Build()
/// solid too — every fight orbits the ring, with brick outcrops as the only cover.
public sealed class DonutArena : IArenaBuilder
{
- public ArenaLayout Build()
+ public ArenaLayout Build(int seed)
{
var (materials, bushes) = ArenaAuthor.Field();
@@ -334,7 +335,7 @@ static double R2(int x, int y)
(PowerupKind.Missile, 23, 10),
};
- return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.Mars, (37, 3), (6, 22));
+ return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.Mars, seed);
}
}
@@ -342,7 +343,7 @@ static double R2(int x, int y)
/// chokepoint) — with the corners outside the plus masked solid Steel.
public sealed class CrossArena : IArenaBuilder
{
- public ArenaLayout Build()
+ public ArenaLayout Build(int seed)
{
var (materials, bushes) = ArenaAuthor.Field();
@@ -371,7 +372,7 @@ public ArenaLayout Build()
(PowerupKind.RapidFire, 65, 22),
};
- return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.ParkingLot, (37, 3), (4, 22));
+ return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.ParkingLot, seed);
}
}
@@ -379,7 +380,7 @@ public ArenaLayout Build()
/// by single-cell Bridge causeways — hold an island or duel across the open water.
public sealed class ArchipelagoArena : IArenaBuilder
{
- public ArenaLayout Build()
+ public ArenaLayout Build(int seed)
{
var (materials, bushes) = ArenaAuthor.Field();
@@ -423,14 +424,15 @@ static void Causeway(CellMaterial[,] mats, int x0, int y0, int x1, int y1)
(PowerupKind.RapidFire, 62, 31),
};
- return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.Sand, (13, 10), (62, 10));
+ return ArenaAuthor.Assemble(materials, bushes, powerups, GroundTheme.Sand, seed);
}
}
/// Shared authoring helpers for the built-in themed arenas: a steel-ringed floor field of the
/// common size, primitive terrain stampers, and the assembler that turns authored terrain into an
-/// with eight spawns. Deterministic — loops and
-/// constants only, so a net host and guest build byte-identical maps.
+/// with eight spawns. Terrain is deterministic —
+/// loops and constants only — and the spawns deterministic per seed, so a net host and guest passing
+/// the shared match seed build byte-identical maps.
internal static class ArenaAuthor
{
public const int Width = 76;
@@ -517,19 +519,18 @@ public static void Ramp(CellMaterial[,] materials, int[,] layers, bool[,] ramps,
ramps[x, y] = true;
}
- /// Stitches authored terrain into a playable : eight symmetric
- /// spawns from (each nudged onto open floor), the player first, the rest as
+ /// Stitches authored terrain into a playable : eight seeded-random
+ /// min-distance-separated spawns from , the player first, the rest as
/// enemies. Spawn placement treats any non-floor cell as blocked, so no start lands in a wall, on
- /// lava, or on a bridge.
+ /// lava, in water, or on a bridge.
public static ArenaLayout Assemble(
CellMaterial[,] materials, bool[,] bushes,
- (PowerupKind Kind, int X, int Y)[] powerups, GroundTheme theme,
- (int X, int Y) primary, (int X, int Y) secondary,
+ (PowerupKind Kind, int X, int Y)[] powerups, GroundTheme theme, int seed,
int[,]? layers = null, bool[,]? ramps = null)
{
bool Blocked(int x, int y) => materials[x, y] != CellMaterial.Floor;
- var spawns = SpawnTable.For(Width, Height, primary, secondary, Blocked);
+ var spawns = SpawnTable.For(Width, Height, SpawnTable.MaxSpawns, seed, Blocked);
var player = spawns[0];
var enemies = spawns.Skip(1).ToList();
var sandbags = new bool[Width, Height];
diff --git a/client/src/GameLogic/ArenaGenerator.cs b/client/src/GameLogic/ArenaGenerator.cs
index f6383cc..ae79940 100644
--- a/client/src/GameLogic/ArenaGenerator.cs
+++ b/client/src/GameLogic/ArenaGenerator.cs
@@ -71,16 +71,18 @@ public GeneratedArena Generate(ArenaGenParams p)
// terrain never land on the water or the bridges (the "claim a cell" rule).
var (riverCells, claimed, approaches) = CarveRiver(p, rng);
- // Spawns first, in opposite corners; then enemies and pickups spread across the field. All are
- // picked on unclaimed interior floor so walls scatter around them, never on them or the river.
- var playerSpawn = PickInRegion(p, rng, chosen, claimed, 1, 1, Third(p.Width), Third(p.Height));
- var player2Spawn = PickInRegion(p, rng, chosen, claimed,
- p.Width - 1 - Third(p.Width), p.Height - 1 - Third(p.Height), p.Width - 2, p.Height - 2);
-
+ // Tank spawns first, via SpawnTable's seeded min-distance placement (target 10, floor 3), so
+ // no two tanks start packed together; then pickups spread across the field. All land on
+ // unclaimed interior floor so walls scatter around them, never on them or the river.
+ var spawnCells = SpawnTable.For(p.Width, p.Height, 2 + p.EnemyCount, rng,
+ (x, y) => IsBorder(p, x, y) || claimed[x, y]);
+ chosen.AddRange(spawnCells);
+ var playerSpawn = spawnCells[0];
+ var player2Spawn = spawnCells[1];
var enemySpawns = new List<(int X, int Y)>();
- for (var i = 0; i < p.EnemyCount; i++)
+ for (var i = 2; i < spawnCells.Count; i++)
{
- enemySpawns.Add(PickInRegion(p, rng, chosen, claimed, 1, 1, p.Width - 2, p.Height - 2));
+ enemySpawns.Add(spawnCells[i]);
}
var pickupCells = new List<(int X, int Y)>();
diff --git a/client/src/GameLogic/SpawnTable.cs b/client/src/GameLogic/SpawnTable.cs
index 159c9e2..c72746a 100644
--- a/client/src/GameLogic/SpawnTable.cs
+++ b/client/src/GameLogic/SpawnTable.cs
@@ -3,93 +3,186 @@
namespace TankGame.GameLogic;
-/// Up to eight spawn cells for a room on a level that only declares one or two: the level's
-/// own spawn, the classic second spawn, and their reflections across the field's centre and both
-/// axes — eight symmetric points, each nudged to the nearest un-blocked cell not already taken, so
-/// eight players never collide and nobody materialises inside a wall. Pure C#: the caller supplies
-/// the blocked predicate.
+/// Seeded random spawn placement with a pairwise minimum distance. The old fixed/mirrored
+/// scheme collapsed centre-ish spawns onto each other and nudged the duplicates to ADJACENT cells,
+/// packing eight tanks together; this one scatters them anywhere eligible instead. Placement aims
+/// for cells between any two spawns, relaxing one cell at a time down
+/// to when the map is too cramped, and below that best-effort maximises
+/// whatever separation the field can still give — two tanks share a cell only when the field itself
+/// is smaller than the spawn count. Pure C#: the caller supplies the eligibility predicate, which
+/// must cover walls AND deadly terrain (lava/water), so no tank materialises inside a wall or on a
+/// cell that kills it. Deterministic per seed, so net peers and replays derive identical spawns.
public static class SpawnTable
{
- /// The most spawns yielded — one per player in an eight-tank match.
+ /// The most spawns requested — one per player in an eight-tank match.
public const int MaxSpawns = 8;
+ /// The pairwise separation (Chebyshev cells) placement aims for.
+ public const int TargetSeparation = 10;
+
+ /// The smallest separation relaxation may accept before going best-effort.
+ public const int FloorSeparation = 3;
+
+ // Re-shuffles per separation level: a greedy pass over one shuffle can miss a seating that
+ // exists, so a few fresh orders are tried before conceding the level and relaxing.
+ private const int TriesPerSeparation = 4;
+
+ public static IReadOnlyList<(int X, int Y)> For(
+ int width, int height, int count, int seed, Func isBlocked) =>
+ For(width, height, count, new Random(seed), isBlocked);
+
+ /// As , but drawing from a
+ /// caller-owned mid-stream (the arena generator's per-attempt rng).
public static IReadOnlyList<(int X, int Y)> For(
- int width, int height, (int X, int Y) primary, (int X, int Y) secondary,
- Func isBlocked)
+ int width, int height, int count, Random rng, Func isBlocked)
{
- // Each seed plus its three reflections (across the centre, the horizontal axis, the vertical
- // axis) spreads eight starts symmetrically over the field. The two declared spawns lead so
- // they never move; the reflections fill the rest of the ring.
- var candidates = new[]
+ var open = new List<(int X, int Y)>();
+ for (var y = 0; y < height; y++)
{
- primary,
- secondary,
- Mirror(primary, width, height),
- Mirror(secondary, width, height),
- (X: primary.X, Y: height - 1 - primary.Y),
- (X: width - 1 - primary.X, Y: primary.Y),
- (X: secondary.X, Y: height - 1 - secondary.Y),
- (X: width - 1 - secondary.X, Y: secondary.Y),
- };
-
- var taken = new HashSet<(int X, int Y)>();
- var spawns = new List<(int X, int Y)>(MaxSpawns);
- foreach (var candidate in candidates)
+ for (var x = 0; x < width; x++)
+ {
+ if (!isBlocked(x, y))
+ {
+ open.Add((x, y));
+ }
+ }
+ }
+
+ for (var separation = TargetSeparation; separation >= FloorSeparation; separation--)
{
- var cell = NearestOpen(candidate, width, height, isBlocked, taken);
- taken.Add(cell);
- spawns.Add(cell);
+ for (var attempt = 0; attempt < TriesPerSeparation; attempt++)
+ {
+ if (TryPlace(open, count, separation, rng) is { } placed)
+ {
+ return placed;
+ }
+ }
}
- return spawns;
+ return BestEffort(open, width, height, count, rng);
}
- private static (int X, int Y) Mirror((int X, int Y) c, int width, int height) =>
- (width - 1 - c.X, height - 1 - c.Y);
-
- // Ring search outward from the candidate until an un-blocked, in-bounds, not-yet-taken cell turns
- // up. The candidate itself wins when open and free (ring 0), so an unobstructed declared spawn is
- // never moved; the taken set keeps the eight distinct.
- private static (int X, int Y) NearestOpen(
- (int X, int Y) from, int width, int height, Func isBlocked,
- HashSet<(int X, int Y)> taken)
+ // One greedy pass over a fresh shuffle: take each cell that keeps the separation to everything
+ // already placed. Null when the pass cannot seat everyone at this separation.
+ private static List<(int X, int Y)>? TryPlace(
+ List<(int X, int Y)> open, int count, int separation, Random rng)
{
- for (var ring = 0; ring < Math.Max(width, height); ring++)
+ if (open.Count < count)
+ {
+ return null;
+ }
+
+ var placed = new List<(int X, int Y)>(count);
+ foreach (var cell in Shuffled(open, rng))
{
- for (var dy = -ring; dy <= ring; dy++)
+ if (FarEnough(cell, placed, separation))
{
- for (var dx = -ring; dx <= ring; dx++)
+ placed.Add(cell);
+ if (placed.Count == count)
{
- if (Math.Max(Math.Abs(dx), Math.Abs(dy)) != ring)
- {
- continue; // only the ring's shell — inner cells were checked already
- }
+ return placed;
+ }
+ }
+ }
+
+ return null;
+ }
- var x = from.X + dx;
- var y = from.Y + dy;
- if (x >= 0 && x < width && y >= 0 && y < height
- && !isBlocked(x, y) && !taken.Contains((x, y)))
+ // Below the floor: farthest-point placement squeezes out whatever separation the field still
+ // gives. Blocked cells join the pool only when the open ones cannot seat everyone (distinctness
+ // beats openness); cells repeat only when the whole field is smaller than the spawn count.
+ private static List<(int X, int Y)> BestEffort(
+ List<(int X, int Y)> open, int width, int height, int count, Random rng)
+ {
+ var pool = open;
+ if (pool.Count < count)
+ {
+ var openSet = new HashSet<(int X, int Y)>(open);
+ pool = new List<(int X, int Y)>(open);
+ for (var y = 0; y < height; y++)
+ {
+ for (var x = 0; x < width; x++)
+ {
+ if (!openSet.Contains((x, y)))
{
- return (x, y);
+ pool.Add((x, y));
}
}
}
}
- // Everything within reach was blocked or already taken. Rather than hand back a duplicate (two
- // tanks materialising on one cell), scan row-major for any distinct in-bounds cell — on a nearly
- // full map, distinctness matters more than landing on a perfectly open tile.
- for (var y = 0; y < height; y++)
+ var placed = new List<(int X, int Y)>(count);
+ if (pool.Count == 0)
{
- for (var x = 0; x < width; x++)
+ return placed; // a zero-area field — nothing to place on
+ }
+
+ placed.Add(pool[rng.Next(pool.Count)]);
+ while (placed.Count < count)
+ {
+ var best = default((int X, int Y));
+ var bestDistance = -1;
+ foreach (var cell in pool)
{
- if (!taken.Contains((x, y)))
+ if (placed.Contains(cell))
+ {
+ continue;
+ }
+
+ var distance = int.MaxValue;
+ foreach (var other in placed)
+ {
+ distance = Math.Min(distance, Chebyshev(cell, other));
+ }
+
+ if (distance > bestDistance)
{
- return (x, y);
+ bestDistance = distance;
+ best = cell;
}
}
+
+ if (bestDistance < 0)
+ {
+ break; // every distinct cell is taken — the field is smaller than the spawn count
+ }
+
+ placed.Add(best);
+ }
+
+ for (var i = 0; placed.Count < count; i = (i + 1) % placed.Count)
+ {
+ placed.Add(placed[i]); // reuse cells round-robin — only reachable on a too-small field
+ }
+
+ return placed;
+ }
+
+ private static List<(int X, int Y)> Shuffled(List<(int X, int Y)> cells, Random rng)
+ {
+ var copy = new List<(int X, int Y)>(cells);
+ for (var i = copy.Count - 1; i > 0; i--)
+ {
+ var j = rng.Next(i + 1);
+ (copy[i], copy[j]) = (copy[j], copy[i]);
+ }
+
+ return copy;
+ }
+
+ private static bool FarEnough((int X, int Y) cell, List<(int X, int Y)> placed, int separation)
+ {
+ foreach (var other in placed)
+ {
+ if (Chebyshev(cell, other) < separation)
+ {
+ return false;
+ }
}
- return from; // truly nowhere left (field smaller than the spawn count) — accept the overlap
+ return true;
}
+
+ private static int Chebyshev((int X, int Y) a, (int X, int Y) b) =>
+ Math.Max(Math.Abs(a.X - b.X), Math.Abs(a.Y - b.Y));
}
diff --git a/client/src/Presentation/Arena/Arena3DScene.cs b/client/src/Presentation/Arena/Arena3DScene.cs
index 177f151..57aedb2 100644
--- a/client/src/Presentation/Arena/Arena3DScene.cs
+++ b/client/src/Presentation/Arena/Arena3DScene.cs
@@ -232,8 +232,8 @@ public override void _Ready()
else if (ArenaBuilders.TryGet(GameSetup.Arena.ToString(), out var builder))
{
// A themed code arena (Forest/Volcano/City/Frozen/Canyon): the builder seam returns a whole
- // layout the same shape as Cliffs, so guests and host build the identical level.
- var layout = builder.Build();
+ // layout the same shape as Cliffs; the per-match arena seed places the spawns.
+ var layout = builder.Build(GameSetup.ArenaSeed);
level = layout.Map;
sandbags = layout.Sandbags;
_playerSpawn = layout.PlayerSpawn;
diff --git a/client/src/Presentation/Arena/NetArena3DScene.cs b/client/src/Presentation/Arena/NetArena3DScene.cs
index 2be1d86..4d0d712 100644
--- a/client/src/Presentation/Arena/NetArena3DScene.cs
+++ b/client/src/Presentation/Arena/NetArena3DScene.cs
@@ -33,8 +33,6 @@ public partial class NetArena3DScene : Node3D
private const byte HostSlot = 0;
private const float TeleportPadRadius = 40f; // a tank centred within this of a pad warps (as solo)
- // The guest spawns where the local game's Player 2 does (mirrors the 2D net scene).
- private static readonly (int X, int Y) GuestSpawn = (25, 7);
private static readonly NVector2 GridOrigin = NVector2.Zero;
// Camera: the same eyeballed ¾ ortho as Arena3DScene.
@@ -62,7 +60,7 @@ public partial class NetArena3DScene : Node3D
private IReadOnlyList _authoredPads = System.Array.Empty();
private float _accumulator;
private byte? _localSlot;
- private int _matchSeed; // the lobby's match seed: drives the spawn shuffle and per-bot AI seeding
+ private int _matchSeed; // the lobby's match seed: drives spawn placement and per-bot AI seeding
// The loading handshake (issue #2): when we enter during the lobby's "loading" phase we report our
// arena is built and then hold — no ticks, no snapshots — until the server flips to "started"
@@ -70,12 +68,9 @@ public partial class NetArena3DScene : Node3D
private bool _awaitingStart;
// The seating plan from the lobby's final roster (placeholder-named AI on empty seats), the
- // shared four-cell spawn table both roles derive identically, and the host's authoritative
- // round state.
+ // seeded spawn table both roles derive identically, and the host's authoritative round state.
private IReadOnlyList _roster = System.Array.Empty();
private IReadOnlyList<(int X, int Y)> _spawns = System.Array.Empty<(int, int)>();
- private (int X, int Y) _primarySpawn;
- private (int X, int Y) _secondarySpawn;
private readonly List<(byte Slot, ITank Tank)> _rosterTanks = new();
private readonly List<(int Team, bool Alive)> _roundStatus = new();
@@ -154,18 +149,15 @@ public override void _Ready()
var cliffs = CliffsArena.Create();
_level = cliffs.Map;
sandbags = cliffs.Sandbags;
- _primarySpawn = cliffs.PlayerSpawn;
- _secondarySpawn = cliffs.EnemySpawns[0];
_authoredPads = cliffs.Pads; // the cross-layer valley↔plateau pair (teleport pads T3)
break;
case NetMapPick.BuiltIn builtIn when ArenaBuilders.TryGet(builtIn.ArenaId, out var builder):
// A themed code arena (Forest/Volcano/City/…): every member builds the identical layout
- // from its id, so host truth and guest rendering agree without map bytes on the wire.
- var themed = builder.Build();
+ // from its id + the shared match seed, so host truth and guest rendering agree without
+ // map bytes on the wire.
+ var themed = builder.Build(lobby.Seed);
_level = themed.Map;
sandbags = themed.Sandbags;
- _primarySpawn = themed.PlayerSpawn;
- _secondarySpawn = themed.EnemySpawns[0];
_authoredPads = themed.Pads; // themed arenas may author pad pairs too
break;
default:
@@ -177,8 +169,6 @@ public override void _Ready()
new ArenaGenParams(dim, dim, seed, EnemyCount: 0, PickupCount: 0));
_level = layout.Map;
sandbags = layout.Sandbags;
- _primarySpawn = layout.PlayerSpawn;
- _secondarySpawn = layout.Player2Spawn;
break;
}
}
@@ -186,8 +176,6 @@ public override void _Ready()
{
_level = LevelMap.Parse(Battlefield01.Text);
sandbags = new bool[_level.Width, _level.Height];
- _primarySpawn = (_level.SpawnX, _level.SpawnY);
- _secondarySpawn = GuestSpawn;
}
_grid = _level.BuildGrid();
@@ -382,12 +370,13 @@ private void OnWelcome(byte slot)
_localSlot = slot;
_roster = NetRoster.Build(
NetworkSession.StartedLobby, slot, NetworkSession.ActiveCode, LobbyProtocol.MaxPlayers);
- _spawns = SpawnTable.For(_level.Width, _level.Height, _primarySpawn, _secondarySpawn,
- (x, y) => _arena.IsBlocked(CellCentre(x, y)));
- // Shuffle spawn assignment with the shared match seed so no slot is nailed to the same cell every
- // match (issue #4). Every peer derives the same spawn list + seed, so host and guest agree.
+ // Seeded random placement, min-distance separated: every peer derives the same spawn list from
+ // the shared match seed, so host truth and guest prediction agree — and the seed varies the
+ // cells each match (issue #4). Lava is drivable (IsBlocked false) yet lethal, so spawn
+ // eligibility excludes it explicitly; water already blocks movement.
_matchSeed = NetworkSession.StartedLobby?.Seed ?? 0;
- _spawns = Shuffled(_spawns, _matchSeed);
+ _spawns = SpawnTable.For(_level.Width, _level.Height, SpawnTable.MaxSpawns, _matchSeed,
+ (x, y) => _arena.IsBlocked(CellCentre(x, y)) || CellMaterials.IsLethal(_grid.GetCell(x, y).Material));
foreach (var seat in _roster)
{
_netStats.Register(seat.Slot, seat.Name, seat.Team); // both roles book the same rows
@@ -1002,18 +991,4 @@ private void BuildGround()
private static NVector2 CellCentre(int x, int y) =>
new(GridOrigin.X + ((x + 0.5f) * TileSize), GridOrigin.Y + ((y + 0.5f) * TileSize));
-
- // Deterministic Fisher-Yates: same list + same seed → same order on every peer.
- private static IReadOnlyList<(int X, int Y)> Shuffled(IReadOnlyList<(int X, int Y)> spawns, int seed)
- {
- var shuffled = new List<(int X, int Y)>(spawns);
- var rng = new System.Random(seed);
- for (var i = shuffled.Count - 1; i > 0; i--)
- {
- var j = rng.Next(i + 1);
- (shuffled[i], shuffled[j]) = (shuffled[j], shuffled[i]);
- }
-
- return shuffled;
- }
}
diff --git a/client/tests/GameLogic/ArenaBuildersTests.cs b/client/tests/GameLogic/ArenaBuildersTests.cs
index 04725f2..2b09973 100644
--- a/client/tests/GameLogic/ArenaBuildersTests.cs
+++ b/client/tests/GameLogic/ArenaBuildersTests.cs
@@ -23,7 +23,7 @@ public void EachRegisteredArena_BuildsAValidPlayableArena(string arenaId)
{
Assert.True(ArenaBuilders.TryGet(arenaId, out var builder), $"{arenaId} must have a builder");
- var layout = builder.Build();
+ var layout = builder.Build(seed: 7);
var grid = layout.Map.BuildGrid();
// Eight starts (player + seven enemies), all distinct and standing on open, non-blocked ground.
@@ -193,7 +193,8 @@ public void Archipelago_IsFloorIslands_InAWaterSea_JoinedByBridgeCauseways()
Assert.True(bridgeOverWater, "archipelago causeways must bridge over the water sea");
}
- // Net sync: host and guest build independently, so two builds must be byte-identical terrain.
+ // Net sync: host and guest build independently from the shared match seed, so two builds with
+ // the same seed must be byte-identical terrain AND the same spawns.
[Theory]
[InlineData("Forest")]
[InlineData("Volcano")]
@@ -220,10 +221,66 @@ public void ThemedArena_IsDeterministic(string arenaId)
}
}
- private static ArenaLayout BuildOrFail(string arenaId)
+ // The seed moves the spawns (no more fixed starts every match) but must never touch the terrain
+ // — the map id alone decides the level a net host and guest both build.
+ [Theory]
+ [InlineData("Forest")]
+ [InlineData("Volcano")]
+ [InlineData("City")]
+ [InlineData("Frozen")]
+ [InlineData("Canyon")]
+ [InlineData("Donut")]
+ [InlineData("Cross")]
+ [InlineData("Archipelago")]
+ public void ThemedArena_SeedMovesTheSpawns_ButNeverTheTerrain(string arenaId)
+ {
+ var a = BuildOrFail(arenaId, seed: 1);
+ var b = BuildOrFail(arenaId, seed: 2);
+
+ Assert.NotEqual(Starts(a), Starts(b));
+ for (var x = 0; x < a.Map.Width; x++)
+ {
+ for (var y = 0; y < a.Map.Height; y++)
+ {
+ Assert.Equal(a.Map.Materials[x, y], b.Map.Materials[x, y]);
+ Assert.Equal(a.Map.Bushes[x, y], b.Map.Bushes[x, y]);
+ }
+ }
+ }
+
+ // Randomised placement must still keep tanks apart — at least the floor separation on every
+ // themed map, whatever the seed (the stuck-inside-each-other bug this scheme replaces).
+ [Theory]
+ [InlineData("Forest")]
+ [InlineData("Volcano")]
+ [InlineData("City")]
+ [InlineData("Frozen")]
+ [InlineData("Canyon")]
+ [InlineData("Donut")]
+ [InlineData("Cross")]
+ [InlineData("Archipelago")]
+ public void ThemedArena_KeepsSpawnsSeparated_ForAnySeed(string arenaId)
+ {
+ for (var seed = 0; seed < 5; seed++)
+ {
+ var starts = Starts(BuildOrFail(arenaId, seed));
+ for (var i = 0; i < starts.Count; i++)
+ {
+ for (var j = i + 1; j < starts.Count; j++)
+ {
+ var d = System.Math.Max(System.Math.Abs(starts[i].X - starts[j].X),
+ System.Math.Abs(starts[i].Y - starts[j].Y));
+ Assert.True(d >= SpawnTable.FloorSeparation,
+ $"{arenaId} seed {seed}: spawns {starts[i]} and {starts[j]} are only {d} apart");
+ }
+ }
+ }
+ }
+
+ private static ArenaLayout BuildOrFail(string arenaId, int seed = 7)
{
Assert.True(ArenaBuilders.TryGet(arenaId, out var builder), $"{arenaId} must have a builder");
- return builder.Build();
+ return builder.Build(seed);
}
private static List<(int X, int Y)> Starts(ArenaLayout layout)
diff --git a/client/tests/GameLogic/ArenaGeneratorTests.cs b/client/tests/GameLogic/ArenaGeneratorTests.cs
index 3624a9f..971bfd3 100644
--- a/client/tests/GameLogic/ArenaGeneratorTests.cs
+++ b/client/tests/GameLogic/ArenaGeneratorTests.cs
@@ -353,6 +353,30 @@ public void Generate_AtDoubleSize_YieldsEightDistinctReachableSpawns_ThatValidat
Assert.True(result.IsValid, string.Join(", ", result.Errors));
}
+ // Tank starts (player + player2 + enemies) must never be packed together — at least the
+ // SpawnTable floor separation apart, whatever the seed (the stuck-inside-each-other bug).
+ [Fact]
+ public void Generate_KeepsTankSpawnsSeparated_ForAnySeed()
+ {
+ for (var seed = 0; seed < 10; seed++)
+ {
+ var arena = new ArenaGenerator().Generate(new ArenaGenParams(32, 32, seed, EnemyCount: 6, PickupCount: 4));
+ var starts = new List<(int X, int Y)> { arena.PlayerSpawn, arena.Player2Spawn };
+ starts.AddRange(arena.EnemySpawns);
+
+ for (var i = 0; i < starts.Count; i++)
+ {
+ for (var j = i + 1; j < starts.Count; j++)
+ {
+ var d = System.Math.Max(System.Math.Abs(starts[i].X - starts[j].X),
+ System.Math.Abs(starts[i].Y - starts[j].Y));
+ Assert.True(d >= SpawnTable.FloorSeparation,
+ $"seed {seed}: starts {starts[i]} and {starts[j]} are only {d} apart");
+ }
+ }
+ }
+ }
+
[Fact]
public void Generate_AtDoubleSize_IsDeterministic_ForAGivenSeed()
{
diff --git a/client/tests/GameLogic/SpawnTableTests.cs b/client/tests/GameLogic/SpawnTableTests.cs
index 23127a0..4245907 100644
--- a/client/tests/GameLogic/SpawnTableTests.cs
+++ b/client/tests/GameLogic/SpawnTableTests.cs
@@ -1,4 +1,6 @@
+using System;
using System.Collections.Generic;
+using System.Linq;
using TankGame.GameLogic;
using Xunit;
@@ -6,64 +8,114 @@ namespace TankGame.Tests.GameLogic;
public class SpawnTableTests
{
+ private static int Chebyshev((int X, int Y) a, (int X, int Y) b) =>
+ Math.Max(Math.Abs(a.X - b.X), Math.Abs(a.Y - b.Y));
+
+ private static int MinPairwise(IReadOnlyList<(int X, int Y)> spawns)
+ {
+ var min = int.MaxValue;
+ for (var i = 0; i < spawns.Count; i++)
+ {
+ for (var j = i + 1; j < spawns.Count; j++)
+ {
+ min = Math.Min(min, Chebyshev(spawns[i], spawns[j]));
+ }
+ }
+
+ return min;
+ }
+
[Fact]
- public void AnOpenField_YieldsEightDistinctSpawns_LedByTheDeclaredCells()
+ public void AnOpenField_MeetsTheTargetSeparation()
{
- var spawns = SpawnTable.For(30, 16, primary: (2, 7), secondary: (25, 7), (_, _) => false);
+ for (var seed = 0; seed < 10; seed++)
+ {
+ var spawns = SpawnTable.For(76, 46, SpawnTable.MaxSpawns, seed, (_, _) => false);
- // The two declared cells lead; the rest are their reflections across the centre and both axes.
- Assert.Equal(new List<(int X, int Y)>
+ Assert.Equal(8, spawns.Count);
+ Assert.Equal(8, new HashSet<(int, int)>(spawns).Count);
+ Assert.All(spawns, s => Assert.True(s.X is >= 0 and < 76 && s.Y is >= 0 and < 46));
+ Assert.True(MinPairwise(spawns) >= SpawnTable.TargetSeparation,
+ $"seed {seed}: pairwise separation {MinPairwise(spawns)} is under the target");
+ }
+ }
+
+ [Fact]
+ public void ACrampedField_RelaxesGradually_ButNeverUnderTheFloor()
+ {
+ // 12x12 cannot seat eight tanks ten apart; the placement must relax — yet never under three.
+ for (var seed = 0; seed < 10; seed++)
{
- (2, 7), (25, 7), (27, 8), (4, 8), (2, 8), (27, 7), (25, 8), (4, 7),
- }, spawns);
+ var spawns = SpawnTable.For(12, 12, 8, seed, (_, _) => false);
+
+ Assert.Equal(8, spawns.Count);
+ Assert.Equal(8, new HashSet<(int, int)>(spawns).Count);
+ Assert.True(MinPairwise(spawns) >= SpawnTable.FloorSeparation,
+ $"seed {seed}: pairwise separation {MinPairwise(spawns)} is under the floor");
+ }
}
[Fact]
- public void EightPlayers_AllGetADistinctOpenCell_EvenWhenCandidatesCollide()
+ public void ATinyField_BestEffort_StillKeepsEverySpawnOnItsOwnCell()
{
- // A tight field where several reflections land on the same cell: the taken-set still nudges
- // each to its own open cell, so eight tanks never share a spawn.
- var spawns = SpawnTable.For(8, 8, primary: (1, 1), secondary: (6, 6), (_, _) => false);
+ // 3x3 can never give separation three, but its nine cells still seat eight tanks distinctly.
+ var spawns = SpawnTable.For(3, 3, 8, seed: 5, (_, _) => false);
Assert.Equal(8, spawns.Count);
- Assert.Equal(8, new HashSet<(int, int)>(spawns).Count); // all distinct
- Assert.All(spawns, s => Assert.True(s.X is >= 0 and < 8 && s.Y is >= 0 and < 8));
+ Assert.Equal(8, new HashSet<(int, int)>(spawns).Count);
+ Assert.All(spawns, s => Assert.True(s.X is >= 0 and < 3 && s.Y is >= 0 and < 3));
}
[Fact]
- public void ABlockedCandidate_NudgesToTheNearestOpenCell()
+ public void AFieldSmallerThanTheSpawnCount_IsTheOnlyTimeCellsAreShared()
{
- var blocked = new HashSet<(int, int)> { (2, 7) };
+ var spawns = SpawnTable.For(2, 2, 8, seed: 5, (_, _) => false);
+
+ Assert.Equal(8, spawns.Count);
+ Assert.Equal(4, new HashSet<(int, int)>(spawns).Count); // all four cells used, then reused
+ Assert.All(spawns, s => Assert.True(s.X is >= 0 and < 2 && s.Y is >= 0 and < 2));
+ }
- var spawns = SpawnTable.For(30, 16, primary: (2, 7), secondary: (25, 7),
- (x, y) => blocked.Contains((x, y)));
+ [Fact]
+ public void BlockedCells_AreNeverChosen_WhileOpenOnesRemain()
+ {
+ // The caller's predicate covers walls AND deadly terrain (lava/water); with the whole left
+ // half blocked, every spawn must sit in the open right half.
+ for (var seed = 0; seed < 10; seed++)
+ {
+ var spawns = SpawnTable.For(40, 40, 8, seed, (x, _) => x < 20);
- Assert.NotEqual((2, 7), spawns[0]);
- var (dx, dy) = (spawns[0].X - 2, spawns[0].Y - 7);
- Assert.True(System.Math.Max(System.Math.Abs(dx), System.Math.Abs(dy)) == 1,
- $"the nudge must land on an adjacent cell; landed {spawns[0]}");
- Assert.Equal((25, 7), spawns[1]); // the open candidates never move
+ Assert.Equal(8, spawns.Count);
+ Assert.All(spawns, s => Assert.True(s.X >= 20, $"seed {seed}: spawn {s} sits on a blocked cell"));
+ }
}
[Fact]
- public void AFullyBlockedField_StillYieldsDistinctSpawns_RatherThanDuplicates()
+ public void AFullyBlockedField_StillYieldsDistinctInBoundsSpawns()
{
- // Every cell blocked: the ring search finds nothing, so the fallthrough must still hand out
- // eight DISTINCT cells — two tanks sharing one spawn is worse than a spawn on a blocked tile.
- var spawns = SpawnTable.For(8, 8, primary: (1, 1), secondary: (6, 6), (_, _) => true);
+ // Nowhere is open: distinctness still beats openness — two tanks must never share a cell.
+ var spawns = SpawnTable.For(8, 8, 8, seed: 3, (_, _) => true);
Assert.Equal(8, spawns.Count);
Assert.Equal(8, new HashSet<(int, int)>(spawns).Count);
+ Assert.All(spawns, s => Assert.True(s.X is >= 0 and < 8 && s.Y is >= 0 and < 8));
+ }
+
+ [Fact]
+ public void TheSameSeed_YieldsTheSameSpawns()
+ {
+ var a = SpawnTable.For(76, 46, 8, seed: 42, (_, _) => false);
+ var b = SpawnTable.For(76, 46, 8, seed: 42, (_, _) => false);
+
+ Assert.Equal(a, b);
}
[Fact]
- public void ACandidateOutsideTheGrid_IsPulledInBounds()
+ public void DifferentSeeds_YieldDifferentSpawns()
{
- // A mirrored spawn can land out of bounds on an asymmetric level; the ring search only
- // accepts in-bounds cells.
- var spawns = SpawnTable.For(10, 10, primary: (0, 0), secondary: (12, 5), (_, _) => false);
+ var a = SpawnTable.For(76, 46, 8, seed: 1, (_, _) => false);
+ var b = SpawnTable.For(76, 46, 8, seed: 2, (_, _) => false);
- Assert.All(spawns, s => Assert.True(
- s.X is >= 0 and < 10 && s.Y is >= 0 and < 10, $"spawn {s} must be in bounds"));
+ Assert.NotEqual(a.ToList(), b.ToList());
}
}
diff --git a/client/tests/Presentation/NetArena3DSceneTests.cs b/client/tests/Presentation/NetArena3DSceneTests.cs
index a604d97..7cfbff8 100644
--- a/client/tests/Presentation/NetArena3DSceneTests.cs
+++ b/client/tests/Presentation/NetArena3DSceneTests.cs
@@ -649,7 +649,8 @@ public void CliffsNetMatch_BuildsTheCrossLayerPadRings()
// End-to-end host-side warp: a guest tank relayed onto the valley pad (cell 2,2) must come out
// on the plateau pad (cell 22,18) — across the map AND one layer up — through the authoritative
- // world alone. Deterministic: every seat is human, so no AI moves or shoots.
+ // world alone. Deterministic: every seat is human, so no AI moves or shoots, and the seeded
+ // spawn placement is fixed per lobby seed.
[Test]
public void CliffsNetHost_WarpsARelayedTank_AcrossTheMapAndUpALayer()
{
@@ -661,34 +662,37 @@ public void CliffsNetHost_WarpsARelayedTank_AcrossTheMapAndUpALayer()
try
{
- // The spawn shuffle is seeded by the lobby seed, so WHICH slot starts at the pad-adjacent
- // primary spawn (1,1) is fixed but opaque — find it. It must be a guest (relayed input);
- // if a shuffle change ever hands it to slot 0, pick a different seed above.
+ // Spawns are seeded-random, so WHICH guest starts nearest the valley pad is fixed per
+ // seed but opaque — find it and drive it. The host (slot 0) has no relayed input, so it
+ // is excluded; if a placement change ever strands every guest behind a wall on the way
+ // to the pad, pick a different seed above.
+ var padA = NetCellCentre(2, 2);
byte? padSlot = null;
+ var best = float.MaxValue;
foreach (var (slot, seated) in scene.Tanks)
{
- if (seated.Position == NetCellCentre(1, 1))
+ var d = System.Numerics.Vector2.Distance(seated.Position, padA);
+ if (slot != 0 && d < best)
{
+ best = d;
padSlot = slot;
}
}
if (padSlot is not byte driven)
{
- throw new Exception("Expected a tank on the Cliffs primary spawn (1,1).");
+ throw new Exception("Expected at least one guest tank to drive onto the pad.");
}
- if (driven == 0)
- {
- throw new Exception("Seed 1 put the HOST on the pad-adjacent spawn — choose another seed.");
- }
-
- // Drive diagonally from (1,1) toward the valley pad at (2,2); ~6 ticks reach its trigger
- // radius, the warp fires inside World.Step, and the remaining ticks roll on the plateau.
- _transport.DeliverInput(new InputFrame(Seq: 1, MoveX: 0.707f, MoveY: 0.707f, Aim: 0f,
- Buttons: 0, Slot: driven));
- for (var i = 0; i < 12; i++)
+ // Home toward the valley pad, re-aiming each tick; the warp fires inside World.Step the
+ // moment the tank enters the pad's trigger radius, lifting it to the plateau layer.
+ uint seq = 0;
+ for (var i = 0; i < 400 && scene.Tanks[driven].Layer != 1; i++)
{
+ var pos = scene.Tanks[driven].Position;
+ var dir = System.Numerics.Vector2.Normalize(padA - pos);
+ _transport.DeliverInput(new InputFrame(Seq: ++seq, MoveX: dir.X, MoveY: dir.Y,
+ Aim: 0f, Buttons: 0, Slot: driven));
scene.Tick(0.05f);
}