The Atlas, part 1: fight where you stand - #3
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 Combat → The 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:HexCoordnever grows a Z axis and a generated fight rides thecombat_startthat 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:
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.tsenforces all of this mechanically: it reads every file insrc/engine/atlas/and fails onMath.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.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 oneOCCUPIESpredicate at all four sites.Math.random()into every log id, which made "combat is deterministic given a seed" quietly untrue.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-mysticwas used on the Marketplace heading but never defined in CSS.Also adds CI
maindeploys on merge, so there was no point at which a broken branch could be caught..github/workflows/ci.ymlnow gates every PR intomainwith typecheck, the full suite, and the same production build the deploy runs.Verified
tsc --noEmitclean,npm run buildclean.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