Files
TheriapolisV3/theriapolis-rpg-implementation-plan-phase4.md
Christopher Wiebe b451f83174 Initial commit: Theriapolis baseline at port/godot branch point
Captures the pre-Godot-port state of the codebase. This is the rollback
anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md).
All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:40:51 -07:00

30 KiB
Raw Permalink Blame History

Theriapolis — Phase 4 + Save/Load — Design & Implementation Plan

Status: Proposed. Targets the codebase state as of 2026-04-20 (worldgen phases 03 complete; ENABLE_RAIL=false; 256×256 world; 83 tests green).

Governing docs: theriapolis-rpg-implementation-plan.md §§5, 6, 11, 12 (binding); theriapolis-rpg-procgen.md / -addendum-a.md (hard constraints).


1. Goals & non-goals

Goals

  1. Make the world inhabitable. A single player actor can stand somewhere in the world, be seen on the map, and move.
  2. Deliver the seamless-zoom promise. Smooth, continuous transition between a bird's-eye world map and a walking-distance tactical view, through one camera in world-pixel space.
  3. Stream tactical detail on demand. Tactical chunks are generated deterministically from (seed, chunkX, chunkY) on first access, cached while in view, and evicted when the player leaves. Never pre-baked; never fully persisted.
  4. Persist what matters, derive the rest. Save = seed + sparse deltas + actor/flag state. Load re-runs the pipeline to reconstruct the world.
  5. Preserve every invariant we have. Determinism tests, polyline-as-source-of-truth, Core-has-no-MonoGame, Addendum A §1/§2, 60-second worldgen budget.

Non-goals (explicit)

  • Combat resolver — Phase 5.
  • Character creation, stats, inventory semantics — Phase 5. Phase 4 uses a placeholder actor record with just Position, FacingDir, Speed.
  • NPCs, quest dialogue, reputation logic — Phase 6. Save schema leaves hooks; code is stubbed.
  • PoI interiors / dungeon generation — Phase 7.
  • Weather / seasons / time-of-day lighting — Phase 8. A WorldClock is introduced (Phase 4 needs travel time) but weather tables are not.
  • Art pass. Tactical placeholders follow the established flat-color + letter convention.
  • Rail re-enable. The flag stays false for the duration of Phase 4. Rail wye code remains exercised by determinism tests when the flag is flipped in future.

2. Current-state inventory (what we can build on)

Confirmed by reading the tree on 2026-04-20:

Piece File Notes
23-stage worldgen pipeline WorldGenerator.cs Deterministic, per-stage FNV-1a hashes in WorldState.StageHashes
Camera with continuous zoom Camera2D.cs TacticalThreshold = 0.8f already wired; Mode flips as zoom crosses threshold
View abstraction IMapView.cs Single-method interface; WorldMapRenderer implements it; tactical renderer slots in
Polylines in world-pixel space Polyline.cs Rendered by LineFeatureRenderer.cs — reusable at both zoom levels
Tactical constants already defined Constants.cs:17-19 TACTICAL_PER_WORLD_TILE=32, TACTICAL_CHUNK_SIZE=64, TACTICAL_WINDOW_WORLD_TILES=3
Stage-hash infrastructure WorldState.cs:45 Save integrity check is already populated for every stage
Screen stack ScreenManager.cs Title → WorldGenProgress → WorldMap; new screen(s) plug in
Seeded RNG with sub-streams SeededRng.cs Need new sub-streams for tactical gen and actor ID assignment

Two facts that materially simplify Phase 4:

  • The zoom crossover is already plumbed. Camera2D.AdjustZoom toggles Mode at 0.8f. Screens just need to pick the right renderer based on camera.Mode.
  • Map is now 256×256. Total world span is 8192×8192 world pixels (vs 16384×16384 before). Every constant-in-pixels we pick for tactical behavior should be sanity-checked against that.

3. Phase 4 architecture

3.1 Coordinate systems (recap)

One canonical space — world pixels. Origin top-left, +Y down, no rotation.

world tile (X,Y)      = region of 32×32 world pixels at [X*32, Y*32, (X+1)*32, (Y+1)*32)
tactical tile (x,y)   = 1 world pixel  (TACTICAL_PER_WORLD_TILE = 32 tactical per world tile)
tactical chunk (cx,cy)= region of 64×64 tactical tiles = 64×64 world pixels = 2×2 world tiles
macro cell            = 8×8 world tiles = 256×256 world pixels (at 256-tile world)

Nothing except rendering converts to screen space; that's Camera2D's job.

3.2 Module layout

Phase 4 adds these directories (all greenfield):

Theriapolis.Core/
  Tactical/
    TacticalTile.cs          struct — surface class, decoration, walkability, etc
    TacticalChunk.cs         class  — 64×64 TacticalTile[,] plus spawn list
    TacticalChunkGen.cs      static — hashed per-chunk generator (deterministic)
    ChunkCoord.cs            struct — (cx, cy) key
    ChunkStreamer.cs         manages in-memory chunk cache & delta overlay
    IChunkDeltaStore.cs      interface — save-layer hook
  Entities/
    Actor.cs                 base — Id, Position, FacingDir, Speed
    PlayerActor.cs           player-only fields (saved explicitly)
    ActorManager.cs          owns live actors; tick their state
  Persistence/
    SaveHeader.cs            JSON-serializable: version, seed, stage hashes, play time, save slot meta
    SaveFile.cs              header + binary body combined file
    SaveCodec.cs             MessagePack body encode/decode
    IPersistable.cs          contract each save-worthy module implements
    SaveMigrations/
      ISaveMigration.cs
      Migrations.cs          registry
  Time/
    WorldClock.cs            in-game time advancement (used by travel)

Theriapolis.Game/
  Rendering/
    TacticalRenderer.cs      IMapView; reads ChunkStreamer
    PlayerSprite.cs          draws player actor in whichever view is active
  Screens/
    PlayScreen.cs            replaces WorldMapScreen once the game is in-play mode
    SaveLoadScreen.cs        save-slot picker (from title and in-game pause)
  Input/
    PlayerController.cs      world-travel + tactical-step input
  Platform/
    SavePaths.cs             OS-aware save directory resolution

Theriapolis.Core must not gain a MonoGame dependency. The architecture test asserts it. Tactical generation, chunk streaming, actor model, and persistence are all Core-only. Rendering, input, and platform paths are Game-only.

3.3 View model — how PlayScreen drives rendering

PlayScreen owns: camera, input, chunk streamer, actor manager, both renderers.

public void Draw(GameTime gt, SpriteBatch _)
{
    _chunks.EnsureLoadedAround(_player.Position, radius: C.TACTICAL_WINDOW_WORLD_TILES);
    var view = _camera.Mode == ViewMode.WorldMap ? _worldView : _tacticalView;
    view.Draw(_sb, _camera, gt);
    _playerSprite.Draw(_sb, _camera, _player);          // drawn in either mode
    _overlay.Render();
}

Because both renderers use the same camera and world-pixel space, the same polylines, the same player position — crossing the zoom threshold is just a renderer swap. No camera repositioning. No state hand-off. This is the seamless-zoom model working as specified in Section 5.

A cross-fade is a future polish; an abrupt swap is acceptable for Phase 4.

3.4 Chunk streaming

Cache policy

The canonical window is a 3×3 grid of world tiles around the player (TACTICAL_WINDOW_WORLD_TILES=3). A world tile is 2×2 tactical chunks (32 tactical tiles per world tile ÷ 64 tactical tiles per chunk side → 0.5; actually: 32/64 = ½, so 1 world tile overlaps 1 chunk on each axis, sometimes 2 if straddled). Recomputing: at TACTICAL_PER_WORLD_TILE=32 and TACTICAL_CHUNK_SIZE=64, each chunk covers 2×2 world tiles. A 3×3 world-tile window therefore touches at most a 3×3 arrangement of chunks (rounded out from wherever the window aligns).

So the steady-state in-memory set is ≤ 9 chunks × 64×64 tiles = 36,864 tactical tiles. If each TacticalTile is 16 bytes, that's ~600 KB. Negligible.

  • Load: on first access, ChunkStreamer.Get(cc) calls TacticalChunkGen.Generate(seed, cc, worldState) then applies any IChunkDeltaStore overlay for the chunk.
  • Evict: any chunk not covered by the current window is dropped. Before dropping, if the chunk has been modified relative to its deterministic baseline, the modified fields are flushed to IChunkDeltaStore.
  • Pre-warm: when the player crosses into a new world tile, the streamer kicks a background Task to Get the chunks that will be in range after the move. Caller never blocks on generation; the main thread calls Get and gets either the cached result or a fresh-generation that took <1 ms on 64×64 (budgeted in §6 below).

Generation algorithm (deterministic, Core-only)

TacticalChunkGen.Generate(ulong worldSeed, ChunkCoord cc, WorldState world) → TacticalChunk

Inputs: the chunk's covered world tiles (2×2), polylines intersecting the chunk's world-pixel AABB, settlement footprints intersecting the AABB, and SubSeed(RNG_TACTICAL, cc.X, cc.Y).

Steps:

  1. Ground layer. For each tactical tile (tx, ty):
    • Resolve parent world tile (wx, wy).
    • Read its biome, elevation, moisture, feature flags.
    • Pick a ground variant deterministically per biome: e.g. Grassland → { shortgrass, tallgrass, dirt, flower } weighted by a hash of (wx, wy, tx, ty).
    • Bias by elevation gradient (rockier near mountain edges).
  2. Polyline burn-in. For every polyline (river, road) intersecting the chunk:
    • Walk its segments; for each segment, rasterize a width-aware stroke into the chunk. Rivers carve water tiles; roads stamp gravel/cobble. Reuse PolylineBuilder.RasterizeToTileFlags-style math but at tactical resolution (1 tile = 1 world pixel).
  3. Settlement burn-in. If a settlement footprint intersects, stamp its building templates. A Tier-5 hamlet is a couple of cottages + a well. Tier-1 Millhaven gets a preset layout from Content/Data/settlement_layouts/. No procedural buildings in Phase 4 — templates only.
  4. Scatter pass. Per biome, sprinkle decorations (trees, rocks, bushes) using a blue-noise-ish Poisson sample seeded from SubSeed(RNG_TACTICAL, cc.X, cc.Y, 1). Walkability of these tiles depends on the decoration type (trees block, bushes slow).
  5. Spawn list. A deterministic encounter roll driven by world.EncounterDensity[wx, wy] and the same sub-seed. Phase 4 stores the spawn list but does not actually spawn NPCs — Phase 5/6 reads it.

All four passes must be pure functions of (worldSeed, cc, world). No ambient RNG. Test: generate the same chunk on two machines; assert byte-identical.

Determinism caveats specific to tactical

  • The player moving around must not change what the world contains. Chunk generation is referentially transparent. The delta store only captures player-caused changes (chopped a tree, dropped an item, cleared a bandit camp).
  • On load, we re-run worldgen, reload the player, then on first access of each chunk the generator runs fresh; deltas overlay on top. A save does not contain any chunk that the player hasn't personally modified.

3.5 Player entity & input

Actor is a plain POCO in Core; PlayerActor adds persistence hooks.

public class Actor
{
    public int       Id;
    public Vector2   Position;        // world-pixel space; reused in both views
    public float     FacingAngleRad;
    public float     SpeedWorldPxPerSec; // continuous-time travel speed on world map
}

Two input modes, both driven by PlayerController:

World-map mode (camera.Mode == WorldMap):

  • Click a destination on the map; compute an A* path over world tiles using the existing road network as cost discount. Player walks the path at SpeedWorldPxPerSec, clock advances proportionally (see §3.6).
  • Shift-click or ESC cancels.
  • No tile-by-tile action. This matches Decision #3 — continuous time-advanced travel.

Tactical mode (camera.Mode == Tactical):

  • WASD / arrow keys step one tactical tile at a time. Each step is a "turn" (Phase 4 doesn't yet consume AP, but the hook is there for Phase 5 combat).
  • Diagonal movement allowed if both cardinal neighbors are walkable (standard tactical D&D rule).
  • Step is blocked by impassable decorations, water without a bridge, settlement walls.

The controller is in Game (needs keyboard input), but the resolution logic — "is this move legal, what's the resulting position" — lives in Core (TacticalMovementRules static class). Testable.

3.6 World clock & travel time

We need a clock in Phase 4 because travel is time-advanced. Weather and seasons read it later in Phase 8.

WorldClock is a single long InGameSeconds counter plus helpers (Day, Hour, Season). It is advanced by:

  • Continuous travel: time += distance / effectiveSpeed. Effective speed = base × terrainModifier × roadModifier. Road tiles give a 2× multiplier (matches the EXISTING_ROAD_COST=0.1f philosophy at the worldgen layer).
  • Tactical steps: each step advances some trivial amount (10 in-game seconds per tactical tile, walking pace). This only matters so tactical play doesn't freeze the world clock, which would break future weather logic.

Travel-time formula (Phase 4 version, deliberately simple, can be tuned in Phase 8):

seconds_per_world_pixel =
    BASE_SEC_PER_WORLD_PIXEL (const)
  * biome_speed_mod[biome]       // 1.0 grassland, 1.5 forest, 3.0 mountain, ...
  * (has_road ? ROAD_SPEED_BONUS : 1.0)

BASE_SEC_PER_WORLD_PIXEL chosen so a Tier-3 to Tier-3 hop over roads takes ~half a day. New constants in Constants.cs:

public const float BASE_SEC_PER_WORLD_PIXEL = 8f;    // ~36h to cross the world on foot
public const float ROAD_SPEED_MULT          = 0.5f;  // roads are 2× faster
public const int   TACTICAL_STEP_SECONDS    = 10;

These are knobs. Playtest in Phase 5+.


4. Save / Load

4.1 Format

File = header JSON + body MessagePack, concatenated with a 4-byte little-endian header length prefix. This lets a quick scan (slot picker) deserialize only the header.

[ 4 bytes: headerLen ][ headerLen bytes: JSON ][ remaining: MessagePack body ]

Header (JSON, System.Text.Json):

{
  "version":        4,               // bump on breaking schema change
  "worldSeed":      "0xCAFEBABE",
  "stageHashes":    { "ElevationGen": 0xAB..., "HydrologyGen": 0xCD..., ... },
  "playerName":     "Grev",
  "playerTier":     3,               // highest settlement tier the player has reached, cheap to show in slot picker
  "inGameSeconds":  483812,
  "realPlayTime":   "PT4H13M",
  "savedAt":        "2026-04-20T18:22:31Z",
  "appVersion":     "0.4.0"
}

Body (MessagePack, binary, MessagePack-CSharp nuget):

[MessagePackObject]
public sealed class SaveBody
{
    [Key(0)]  public PlayerActor Player;
    [Key(1)]  public WorldFlags Flags;                   // global quest/story flags
    [Key(2)]  public FactionDeltas Factions;             // per-faction influence offsets from baseline
    [Key(3)]  public QuestState Quests;                  // empty in Phase 4
    [Key(4)]  public ReputationState Reputation;         // empty in Phase 4
    [Key(5)]  public List<int> DiscoveredPoiIds;
    [Key(6)]  public Dictionary<ChunkCoord, ChunkDelta> ModifiedChunks;
    [Key(7)]  public List<WorldTileDelta> ModifiedWorldTiles;
    [Key(8)]  public WorldClockState Clock;
}
  • Phase 4 writes Player, DiscoveredPoiIds, ModifiedChunks, ModifiedWorldTiles, Clock.
  • Phase 4 reserves Flags, Factions, Quests, Reputation as empty containers. This means Phase 5/6 adds no migration — the schema already covers them.

4.2 Save procedure

  1. Flush in-memory chunk deltas through IChunkDeltaStore so nothing is lost.
  2. Populate SaveBody from live systems.
  3. Take world.StageHashes as-is and embed in header.
  4. Serialize header (JSON), compute its byte length, write prefix + header + body to a temp file.
  5. Atomic rename over the target slot file. Crash-safe: if the rename fails or is interrupted, the previous save remains intact.

Save location (per SavePaths.cs):

  • Windows: %LOCALAPPDATA%\Theriapolis\Saves\slot_N.trps
  • Linux: $XDG_DATA_HOME/Theriapolis/saves/slot_N.trps (fallback ~/.local/share/Theriapolis)
  • macOS: ~/Library/Application Support/Theriapolis/Saves/slot_N.trps

Slot count: 10. Plus one autosave.trps slot managed by the game (writes on screen transitions that matter: exiting a settlement, completing a travel leg).

4.3 Load procedure

  1. Read header. Validate version (see §4.4). Reject on unknown.
  2. Re-run worldgen from worldSeed. This is the hot path — measured today at ~30 s at 512×512, faster at 256×256 (target: < 10 s). Show the existing WorldGenProgressScreen.
  3. Compare each stage's recomputed hash against header.stageHashes. Any mismatch → enter migration flow (§4.4).
  4. Read body. Restore PlayerActor, WorldClock, DiscoveredPoiIds.
  5. Restore ModifiedWorldTiles by patching world.Tiles in place. (Player burned Millhaven's mill? Its tile's Biome goes to Ashland per the delta.)
  6. Seed the chunk delta store from ModifiedChunks. No chunk is actually generated — they'll lazy-load around the player's spawn.
  7. Transition to PlayScreen.

4.4 Migration policy

There are three possible outcomes when stageHashes mismatch:

Mismatch location Policy Rationale
Any stage ≤ BiomeAssign (tile arrays) Hard block. Prompt user: "This save was made with an older worldgen. Start new game from the same seed?" The player's position is anchored in tile space. If the biome under them changed, we cannot safely keep their position. Their faction state could also have been earned in a world that no longer exists.
HydrologyGen through PolylineCleanup (polylines) Soft warning + proceed. Log every polyline whose id-to-position mapping shifted. Player keeps everything; world may have a differently-shaped river next to their house. Polylines don't anchor player state. Deltas that referenced a polyline by id will still work because the id survives; the shape is just different.
Settlement* through ValidationPass Soft warning + proceed, but run a post-load sanity check: if the player was standing inside a settlement footprint that no longer exists, teleport them to the nearest Tier ≥ 3 settlement. Log. Settlements can shift slightly when distance constants change — we already saw this with the 256×256 rescale.

All migrations register under Persistence/SaveMigrations/. A migration is (fromVersion, toVersion, Action<SaveFile>). Registry picks the chain and runs it. If no chain exists, header version determines behavior per the table above.

4.5 IPersistable pattern

Modules that own save-worthy state implement:

public interface IPersistable<TState>
{
    TState CaptureState();
    void   RestoreState(TState state);
}

ActorManager : IPersistable<PlayerActor>, WorldClock : IPersistable<WorldClockState>, ChunkStreamer : IPersistable<Dictionary<ChunkCoord, ChunkDelta>>, etc. SaveCodec wires them. Adding a new module in Phase 5 is just: implement the interface, add a MessagePack key, bump version, add a no-op migration from the previous version.


5. Changes to existing files

5.1 Constants.cs additions

// ── Phase 4: Tactical streaming ────────────────────────────────────────
public const ulong RNG_TACTICAL_GROUND  = 0x7AC71C01UL;
public const ulong RNG_TACTICAL_SCATTER = 0x7AC71C02UL;
public const ulong RNG_TACTICAL_SPAWN   = 0x7AC71C03UL;
public const int   CHUNK_CACHE_SOFT_MAX = 16;   // allow a little slack past the 9-chunk window

// ── Phase 4: Actor + clock ─────────────────────────────────────────────
public const float BASE_SEC_PER_WORLD_PIXEL = 8f;
public const float ROAD_SPEED_MULT          = 0.5f;
public const int   TACTICAL_STEP_SECONDS    = 10;
public const ulong RNG_ACTOR_ID             = 0xAC704DUL;

// ── Phase 4: Save ──────────────────────────────────────────────────────
public const int   SAVE_SCHEMA_VERSION      = 4;

The existing RNG_TACTICAL = 0x7AC71CA1UL stays as a parent; the three new sub-streams are specifically for the tactical gen passes. No collisions.

5.2 WorldState.cs

No schema change. Already has StageHashes dict. Phase 4 just reads from it.

5.3 WorldMapScreen.csPlayScreen.cs

WorldMapScreen is preserved as the debug/post-worldgen viewer; a new PlayScreen becomes the in-game screen. Differences:

  • Takes an additional PlayerActor, ActorManager, ChunkStreamer, WorldClock.
  • Swaps renderers based on camera.Mode.
  • PlayerController replaces the click-to-copy-clipboard debug handler (that moves back to WorldMapScreen behind a --dev flag).

The title screen routes: new game → WorldGenProgressScreen(seed)PlayScreen. Load game → SaveLoadScreenWorldGenProgressScreen(seedFromSave)PlayScreen (with save-file applied post-worldgen).

5.4 Architecture test

Extend CoreNoDependencyTests to ensure new Core namespaces Theriapolis.Core.Tactical, Theriapolis.Core.Entities, Theriapolis.Core.Persistence, Theriapolis.Core.Time have no MonoGame refs.


6. Performance & memory budgets

Item Target Notes
Single chunk gen < 5 ms 64×64 = 4096 tiles; biome lookup + scatter + polyline burn-in. Will measure.
3×3 world-tile window load from cold < 30 ms First entry to tactical after worldgen. Player will briefly see a placeholder while we stream in.
Pre-warm on world-tile crossing < 5 ms on main thread Background Task does the real work; main thread is just kicking it.
Steady-state tactical memory < 5 MB 9 chunks × 600 KB + decoration sprite refs.
Save write < 100 ms For a 10-hour playthrough save. Dominated by MessagePack serialization.
Save load (excluding worldgen) < 500 ms Deserialize + apply deltas + seed chunk cache.
Full load (worldgen + save apply) < 10 s Dominated by worldgen. 256×256 pipeline is already fast.

These are targets to hold, not guesses. Each gets a test (§7.5).


7. Testing strategy

Reuses the conventions already established — WorldCache for fixture-shared worldgen output, FNV-1a hashes for determinism, xUnit Theory for multi-seed checks.

7.1 Chunk determinism

[Theory, MultipleSeeds]
public void Chunk_GeneratesSameBytesAcrossRuns(ulong seed)
{
    var w = _cache.Get(seed).World;
    var a = TacticalChunkGen.Generate(seed, new ChunkCoord(5, 5), w);
    var b = TacticalChunkGen.Generate(seed, new ChunkCoord(5, 5), w);
    Assert.Equal(Hash(a), Hash(b));
}

7.2 Stream cycle invariance

Generate a chunk → evict → regenerate → hash must match the first. Confirms no lingering state corrupts a fresh gen.

7.3 Delta round-trip

Mutate a few tiles in a chunk → flush to delta store → evict → reload → assert post-delta state matches. This is the single most important save-correctness test.

7.4 Save/load round-trip

Play a scripted 30-second session (move player, mutate one chunk, advance clock) → save → load → assert every IPersistable.CaptureState() produces the same state.

7.5 Performance guards

[Fact]
public void ChunkGen_Under5ms()
{
    var w = _cache.Get(0xCAFEBABEUL).World;
    var sw = Stopwatch.StartNew();
    for (int i = 0; i < 100; i++) TacticalChunkGen.Generate(0xCAFEBABEUL, new(i, 0), w);
    Assert.True(sw.Elapsed.TotalMilliseconds / 100 < 5);
}

Flakes are handled by taking the median of ten runs and asserting against a 1.5× of the target. Keeps us honest without false-failing on a busy CI box.

7.6 Migration tests

For each supported migration chain, construct a minimal save of the old version, run it through migration, assert the new-version SaveBody has the expected field values.

7.7 Existing tests stay green

All 83 current tests must continue to pass. The determinism suite, in particular, is the canary: if a Phase 4 change mutates a Stage* hash, something has accidentally perturbed worldgen. Every such change must be intentional and explained in the commit message.


8. Milestones

Sized in "chunks of work" rather than calendar days. Each milestone ends with the test suite green and a demoable state.

M1 — Actor skeleton (no streaming yet).

  • Actor, PlayerActor, ActorManager in Core.
  • PlayerSprite in Game.
  • Click on world map → player walks to that tile at constant speed; clock advances.
  • WorldClock ticking.
  • Save/load slot picker UI (stubs).
  • Ship point: can wander around Theriapolis on the world map like a slow cursor.

M2 — Tactical chunk gen, no rendering.

  • TacticalTile, TacticalChunk, TacticalChunkGen in Core.
  • Deterministic generation tests.
  • headless-tactical-dump tool command that renders one chunk to PNG, for eyeballing biome coverage and polyline burn-in.
  • No Game-side integration yet. Purely Core work.
  • Ship point: tools + tests prove tactical gen is correct.

M3 — Tactical renderer + zoom crossover.

  • TacticalRenderer : IMapView.
  • ChunkStreamer wired up in PlayScreen.
  • Zoom past TacticalThreshold switches to tactical render; player sprite stays put.
  • Chunks stream in as the player moves through tiles.
  • WASD-step movement on tactical.
  • Ship point: walk into Millhaven at world scale, scroll wheel in, start stepping through its streets.

M4 — Save/load end-to-end.

  • Save file format, SaveCodec, slot picker wired.
  • Autosave on screen transitions.
  • Migration scaffold with one test migration (v3→v4, hypothetical).
  • Ship point: save, quit, restart, load, pick up where you left off.

M5 — Delta persistence for chunks & world tiles.

  • IChunkDeltaStore implementation.
  • Smoke test: chop a tree, leave the chunk, come back, tree is still gone.
  • Ship point: the world remembers player actions.

M6 — Polish & performance.

  • Pre-warm tuning, cache sizing, eviction correctness.
  • Cross-fade polish on the zoom swap (optional).
  • Performance guards green on CI.
  • Ship point: a playable, save-capable Phase 4.

9. Risks & mitigations

Risk Likelihood Impact Mitigation
Tactical gen proves too slow on mid-range hardware Med High Build M2 first; measure before integrating. If we miss the 5 ms budget, reduce scatter density or cache per-biome scatter templates.
Chunk boundaries show visible seams Med Med Deterministic gen means adjacent chunks see the same inputs at the seam. Real risk is when a chunk reads a "halo" of neighbors for scatter and we don't give it enough halo. Budget 2 tactical tiles of halo read.
Save schema churn across Phase 5/6 High Med The IPersistable pattern is designed for this. Reserved fields (Flags, Factions, Quests, Reputation) mean no migration needed for those in Phase 5/6.
Worldgen determinism regressions from Phase 4 changes Low Critical Determinism tests gate every commit. If a tactical change ever mutates a Stage* hash, something is wrong — investigate, don't silence.
Player standing inside a no-longer-existent settlement on load Med Low Covered by §4.4 teleport-to-nearest-Tier-3 rule.
Continuous world travel through unwalkable terrain Med Med Path A* on worldgen uses the existing road-cost fabric; extend it to include an "unwalkable" cost for ocean/high mountain. Settle this in M1.
"Empty expanses" returning at tactical scale Low Med We've already halved settlement distances. Tactical scatter density is independent and tuned per biome.

10. Open questions

These are decisions worth making explicit before M1 starts. None is a blocker; flagging them for conversation.

  1. Travel cancellation ergonomics. If the player clicks a second destination mid-travel, do we abort the first path immediately or queue? Proposed: abort, treat the new click as authoritative.
  2. Encounter triggering during travel. Phase 4 rolls spawn lists but doesn't resolve encounters. When do we actually pause travel and drop into tactical? Probably Phase 6 concern, but worth thinking about so we don't bake in wrong assumptions.
  3. Tactical movement UI. WASD per-step is fine for debug; is there a "click a destination tile and auto-path" mode for tactical too? Probably — defer to M3 so we can feel the step-at-a-time version first.
  4. Save slot naming. Player-chosen names vs autogenerated (Grev, Day 12, near Millhaven)? Proposed: autogenerated with an override option. The header already has enough data for a nice auto-string.
  5. Dev-mode preserve. Keep the click-to-copy-clipboard debug affordance somewhere? I'd argue yes — add a --dev command-line flag that keeps WorldMapScreen reachable from the title.

11. What Phase 4 does not finish, and why that's OK

Phase 4's exit criteria are that a player exists, moves around the world at two scales, and their progress is saved. That is all. It deliberately leaves out:

  • Combat. The hook is the tactical-step turn counter; Phase 5 reads it.
  • Dialogue. The hook is settlement footprints being addressable by id; Phase 6 reads them.
  • Content richness. Biome scatter is flat placeholders; artists come later.
  • Wang corner-based autotiling between dissimilar surfaces. Phase 4 ships the Option B colour-blend edge overlay code in TacticalRenderer.cs (DrawChunkEdgeBlends) but it's gated off via EdgeBlendEnabled = false. Initial tuning (55% tint alpha, 16-pixel falloff, 4 overlapping masks per tile) washed colours out badly when surrounding placeholder colours had high saturation (snow≈white, sand=cream). The code stays in tree as a starting point for a re-tune once we have real per-tile art and can judge subtler blend parameters. The full plan is "Option C": replace the per-edge blend entirely with proper Wang transition tiles generated by Pixellab's create_topdown_tileset. Each terrain pair gets its own 16-tile transition strip; each cell samples its 4 corners' surfaces and picks the matching tile. Switch over once the per-tile art set is finalised — both sides of the renderer (per-tile sprites and edge handling) need cohesive art before the upgrade is worth the refactor.

The payoff of holding the line on scope is that Phase 5 (combat + rules + character creation) has one thing to plug into, not three. Ship Phase 4 narrow, ship it solid, and the rest comes up cleanly on top.