Skip to content

The Atlas, part 1: fight where you stand - #3

Merged
Justrada merged 4 commits into
mainfrom
feature/atlas-maps
Jul 29, 2026
Merged

The Atlas, part 1: fight where you stand#3
Justrada merged 4 commits into
mainfrom
feature/atlas-maps

Conversation

@Justrada

Copy link
Copy Markdown
Owner

Reopened against main — this was PR #2, which GitHub auto-closed when its stacked base branch was deleted on merge of #1. Same commits, same branch.

Overnight work on the map feature. This is the foundation plus a visible first slice — you can pick a ground type when staging a fight and the battlefield is generated, drawn, and enforced.

What to look at first

docs/MAPS.md is the real deliverable — the canonical design for the whole feature, with the reasoning recorded so it outlives any one implementation. §9 has five open questions that are genuinely yours to answer.

Then: host a table → Begin CombatThe Ground.

How the design was chosen

A 14-agent council: 4 recon agents mapped the existing seams, 3 architects proposed independent architectures under deliberately opposed priors, 6 adversarial judges attacked them, and a chief architect synthesised. Separately, deep research into procedural generation, hex tactical design, and the VTT map market.

The judges did something better than agree with me — they read this working tree and found that all three proposals were written against stale recon and would have replaced the edge-wall arena model with something worse. The synthesis kept what had shipped and designed the world layer around it.

The architecture, in one line

The world is a seed. The battlefield is compiled data.

Every hard constraint here is a constraint on bytes — clipboard-only sharing, ~5 MB of shared localStorage, PeerJS with no chunking, SVG-only rendering. A world is about one fifth the size of skillTree.json. The atlas doesn't point at a battlefield, it compiles one, which is why the map ships with zero new combat concepts: HexCoord never grows a Z axis and a generated fight rides the combat_start that already exists.

What's in here

Walls are edges, not cells. A wall sits on the border between two hexes, so a seven-room interior costs ~100 edge records and zero standable ground — versus ~93 of 252 hexes (37% of the board) if walls were tiles. It also gives doors the right arity: an edge door joins exactly two hexes, where a door hex would open in six directions. Foundry and Roll20 both model walls this way.

Line of sight gates targeting at any range, including Battlefield. It's deliberately permissive — the hex-line rounding isn't symmetric (8 of 31,626 hex pairs disagree about who can see whom), and players forgive "they shouldn't have seen me" more readily than "I can't shoot the thing I'm looking at."

Three arena archetypes, each designed against a specific failure:

  • interior — BSP on the offset rectangle, because axial space is sheared and subdividing there yields parallelograms rather than rooms. Doors follow a spanning tree plus loops: a pure tree gives every room one approach and the fight becomes a queue.
  • cave — cellular automata at B4/S4. The canonical 4-5 rule is tuned for eight neighbours and does not transfer to six; ported naively it gives mush or solid rock.
  • open — obstacles at minimum separation 2, which makes the field connected by construction rather than by luck.

Tests assert across 40 seeds per archetype that no hex is stranded and both deploy zones are standable and mutually reachable.

Determinism primitives chosen against measurements, not familiarity: sfc32 over the Weyl generators (where a seed is an offset into one global sequence rather than a selector between independent ones), 53-bit seeds (32-bit collides around 65k entities), prefix-free path hashing (otherwise ['ab','c'] and ['a','bc'] become the same place), and an fBm normaliser that's fixed rather than a running sum — which is what makes zoom add detail instead of rescaling terrain that's already on screen.

purity.test.ts enforces all of this mechanically: it reads every file in src/engine/atlas/ and fails on Math.random, Date.now, console, timers, DOM, React, or a store import.

Bugs found and fixed along the way

Each of these was real and pre-existing:

  • HexBoard's layout memo had an empty dependency array while reading the grid size — it would have silently kept rendering the first geometry it ever computed.
  • The reachable-tile highlight ignored terrain, so the board would have offered tiles through walls the engine then refuses. The action menu had the same gap.
  • Occupancy was three different rules. The engine tested currentHP > 0, so a mover walked onto an unconscious body; the store and board tested !isDead. The board's hex→combatant map is last-writer-wins, so the stacked combatant vanished. Now one OCCUPIES predicate at all four sites.
  • The engine mixed Math.random() into every log id, which made "combat is deterministic given a seed" quietly untrue.
  • A live multiplayer bug: the GM never sent hello, so the handshake was one-sided. A GM page-reload restarts the outbound sequence counter at 0 while a player still holds a high watermark — and every subsequent snapshot is dropped silently, forever. The GM now greets joiners and the player re-baselines.
  • text-glow-mystic was used on the Marketplace heading but never defined in CSS.

Also adds CI

main deploys on merge, so there was no point at which a broken branch could be caught. .github/workflows/ci.yml now gates every PR into main with typecheck, the full suite, and the same production build the deploy runs.

Verified

  • tsc --noEmit clean, npm run build clean.
  • 438 tests across 42 files, up from 311. Every pre-existing test passes untouched — the arena is an optional field, so a fight without one behaves exactly as before.
  • Checked in the browser: staged an interior fight, confirmed walls render on the hex borders, closed doors show as gold dashes, and both foes deploy into their nominated zone.

Not in here

Phases 2–7 from the doc: the scale ladder and zoom camera, multi-floor structures, the authoring tools, split-party sync, marketplace bundling, and the globe.

🤖 Generated with Claude Code

Justrada and others added 4 commits July 29, 2026 00:49
Groundwork for the map feature: a fight can now happen in a *place* rather
than only in the default open rectangle.

`CombatState.battlefield` is optional, so every existing fight is byte-for-byte
unchanged — omitting it reproduces the old behaviour exactly, which is why all
311 previous tests pass untouched.

Walls are stored as EDGES between hexes, not as blocked hexes. Measured on the
real 18x14 board, a 7-room interior costs ~100 edge records and zero standable
ground, versus consuming ~93 of 252 hexes (37% of the board) if walls were
cells. It also gives doors the right arity: an edge door connects exactly two
hexes, where a door *hex* would leak in six directions. Foundry VTT and Roll20
both model walls as segments for the same reasons.

- `edgeId(hex, dir)` packs a border into one integer, with each hex owning
  three of its six edges so both sides agree on the id. Verified injective.
- `Battlefield` stays JSON-safe (arrays, string-keyed doors) because it rides
  inside CombatState over PeerJS and into localStorage; `compileTerrain` builds
  the Set/Map lookups the engine probes.
- `hasLineOfSight` walks the existing `hexLineDraw`. It is deliberately
  permissive: that function's rounding is not symmetric (8 of 31,626 hex pairs
  on this board disagree), so sight is granted if either direction is clear.
  Players forgive "they shouldn't have seen me" more than "I can't shoot the
  thing I'm looking at".
- Threaded terrain through Move, Flee, Chase, targeting and AoE — and through
  forced movement, which walks its own step loop and would otherwise have been
  the one way to shove someone through solid stone.
- Out-of-range and behind-cover now read differently in the log.
- `deployHexes` accepts explicit zones. The row-based default reserves 8 of 14
  rows, which is most of a small board and meaningless on an irregular one.
- `normalizeBattlefield` clamps dims at the trust boundary; an inbound
  100000x100000 arena would hang the client before it was ever playable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The arena type shipped in 486af64, but nothing in the app could actually
create one — `startCombat` took only combatants and `placeCombatants`
hardcoded BATTLE_GRID. This wires it through and fixes the latent bugs that
surfaced once a fight could happen somewhere other than the default rectangle.

  startCombat(combatants, battlefield?, groupId?)

Both new arguments are optional, so every existing caller compiles unchanged
and all 358 prior tests pass untouched.

Fixed along the way, each independently real:

- HexBoard's `layout` memo read BATTLE_GRID with an EMPTY dependency array, so
  the board would have silently kept rendering whatever geometry it computed
  first. Invisible until an arena changes size — and then baffling.
- The reachable-tile highlight called `reachableHexes` with no terrain, so the
  board would have offered the player tiles through walls that the engine then
  refuses to move to. The action menu had the same gap. A map that lies to the
  player is worse than no map.
- Occupancy was three different rules: the engine tested `currentHP > 0` (so a
  mover walked *onto* an unconscious body), while the store and board tested
  `!isDead`. The board's hex->combatant map is last-writer-wins, so the stacked
  combatant simply vanished. Now one exported `OCCUPIES` predicate at all four
  sites: a downed body blocks, a corpse doesn't.
- The engine mixed `Math.random()` into every log id, which made "combat is
  deterministic given a seed" quietly untrue — a poor foundation for a sold map
  whose whole value rests on reproducibility. Ids are now derived from round +
  counter, and a replayed round is byte-identical.
- Board scale was shrink-only, so a 10x8 tavern would render as a postage stamp
  in the middle of a wide screen. It may now magnify to 2.5x.

Also fixes a live multiplayer bug found while tracing the snapshot gate: the GM
never sent `hello`, so the handshake was one-sided. A GM page-reload restarts
the outbound sequence counter at 0 while a player still holds a high watermark,
and every subsequent snapshot is dropped silently, forever. The GM now greets
joiners and the player re-baselines on it.

docs/MAPS.md records the full map architecture — chosen after a 14-agent
council (3 independent architectures, 6 adversarial judges, synthesis) plus
deep research into procedural generation and the VTT map market. Notably the
judges verified against this working tree and confirmed the edge-wall arena
model over all three proposed replacements.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The first slice of the map feature that a player can actually see: pick a
ground type when staging a fight, and the battlefield is generated,
rendered, and enforced.

`src/engine/atlas/` is the worldgen root, and it is pure — `purity.test.ts`
reads every file in the directory and fails the build on `Math.random`,
`Date.now`, `console`, timers, DOM, React, or a store import. Worlds are
addressed lazily and reproduced independently on every peer, so an impurity
here doesn't fail loudly, it makes two players' maps quietly disagree.

rand.ts — determinism primitives, chosen against measurements rather than
familiarity:

- sfc32 (128-bit state), not mulberry32/splitmix32. Those are Weyl
  generators, so a seed is an *offset into one global sequence* rather than a
  selector between independent ones — every child of a world would walk the
  same loop and siblings would overlap.
- 53-bit seeds. Birthday collisions among 32-bit seeds start around 65k
  entities, which a world with named buildings reaches easily.
- Path hashing is prefix-free. Without type tags and a length prefix,
  ['ab','c'] and ['a','bc'] hash identically — two different places in the
  world silently becoming the same place.
- `deriveSeed(parent, key)` is a pure function of its arguments, never of a
  call order. That is the whole laziness mechanism: room K4 resolves without
  generating K0..K3, and adding a sibling never perturbs an existing one,
  which is why a hand edit keyed by path can survive regeneration.
- fBm divides by a FIXED `1/(1-gain)`, not the running sum of amplitudes.
  That gives the prefix property — the 8-octave value is exactly the first 8
  terms of the 15-octave value — so zooming in adds detail without moving
  terrain already on screen. The running-sum version rescales the coarse
  result every time an octave is added, and the landscape breathes as you zoom.
- Noise samples carry an irrational domain offset. A sphere's axes align
  exactly with the noise lattice at the poles and the date line, where
  Math.floor sends mirror points into different simplices.

battlefield.ts — `compileBattlefield(seed, archetype)` returns the shipped
JSON-safe Battlefield, so nothing about combat changes to accept a generated
arena. Three archetypes, each designed against a specific failure:

- interior: BSP on the OFFSET rectangle, not axial — axial space is sheared,
  so subdividing there yields parallelograms rather than rooms. Doors are cut
  along a spanning tree, plus extra doors for loops: a pure tree gives every
  room one approach and the fight becomes a queue.
- cave: cellular automata at B4/S4, 45% fill. The canonical 4-5 rule is tuned
  for the eight neighbours of a square grid and does not transfer; on six it
  gives either mush or solid rock.
- open: obstacles at minimum separation 2, which makes the field connected
  *by construction* — no two obstacles adjacent means each is an isolated
  cell whose neighbours form a ring, so removing it cannot disconnect anything.

Every archetype ends with a reachability sweep, and the tests assert across 40
seeds each that no hex is stranded and both deploy zones are standable and
mutually reachable.

Rendering: walls draw in ONE board-spanning overlay rather than per tile —
each HexTile is its own <svg>, so a shared border would be painted twice with
doubled opacity and two independent entrance animations. Solid hexes read as
mass rather than floor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pushes to main deploy straight to the live game, so there was no point at
which a broken branch could be caught — deploy.yml only runs once the code
is already shipping. This gates the pull request instead: typecheck, the
full suite, and the same production build the deploy performs (base path
included, so a PR can't pass here and fail on the way out).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Justrada
Justrada merged commit 6ea6720 into main Jul 29, 2026
1 check passed
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