# MULTIPLAYER_ARCHITECTURE.md

Analysis of the Shattered Pixel Dungeon codebase as a basis for extending it into a
**2-player online co-op roguelike** (friends only — no security/anti-cheat considerations).
Both players share one dungeon and play **turn by turn, one after another**.

All paths relative to repo root. `CR` = `core/src/main/java/com/shatteredpixel/shatteredpixeldungeon/`.
Line numbers verified against this checkout (v3.3.x era codebase, 1187 java files in core).

---

## 0. Executive summary

The good news: this codebase is far better suited to this than most single-player games:

- The entire simulation is a **deterministic-looking, time-scheduled turn engine** (`Actor`) with a
  clean pause point: `Hero.act()` returning `false` parks the actor thread and waits for input
  (`CR/actors/Actor.java:244-326`, `CR/actors/hero/Hero.java:831-929`). That pause point is exactly
  where a remote player's action can be awaited. The user's hunch is confirmed: **mob AI already
  targets arbitrary non-player characters** via the `Char.Alignment` system
  (`CR/actors/mobs/Mob.java:275-442`) — a second hero can be made a first-class target with modest work.
- All game state is **serializable as gzipped JSON via `Bundle`** (`SPD-classes/.../watabou/utils/Bundle.java`),
  partitioned into one `game.dat` + one file per visited depth. This is directly reusable as the
  network sync format and doubles as the reconnect mechanism.
- Level generation is **seed-deterministic** (`Dungeon.seedForDepth`, `CR/Dungeon.java:418-431`), and
  generated levels are persisted as whole bundles — so level content can simply be *shipped*, not
  re-simulated.

The bad news (the bulk of the work):

- The game is **saturated with global singletons**. `Dungeon.hero` is referenced in **~320 files**,
  `Dungeon.level.heroFOV` (the single-player view array) in **~175 files**. There is exactly one hero,
  one FOV, one camera, one status pane, one inventory — all static.
- The "turn" is **not a discrete unit**: it is a float-time scheduler with rebasing (`Actor.fixTime()`),
  so "player A's turn, then player B's turn" must be layered on top deliberately.
- RNG is a **global mutable stack** shared across threads — full lockstep determinism is off the table.
  Recommended: **host-authoritative simulation with command input + state/event replication.**

Recommended architecture in one sentence:

> **Host runs the full vanilla simulation; both clients send serialized "action commands" at the
> hero-input pause point; host replicates world snapshots (existing Bundle format) plus an
> event stream; guests render a mirrored scene with per-player FOV.**

---

## 1. Codebase map (what lives where)

| Area | Location | Notes |
|---|---|---|
| Turn engine | `CR/actors/Actor.java` | time-scheduled actor loop, runs on dedicated thread |
| Characters | `CR/actors/Char.java` (base), `CR/actors/hero/Hero.java` (2637 lines), `CR/actors/mobs/Mob.java` | |
| Buffs / blobs | `CR/actors/buffs/Buff.java`, `CR/actors/blobs/Blob.java` | both are `Actor`s |
| World state | `CR/Dungeon.java` (all static), `CR/levels/Level.java` | one hero, one level, one depth |
| Level gen | `CR/levels/*Level.java`, `CR/levels/painters/`, `CR/levels/rooms/` | seeded via `Random.pushGenerator` |
| Scenes | `CR/scenes/` — `GameScene` (world+UI), `InterlevelScene` (transitions), `CellSelector` (input) | libGDX `noosa` scene graph |
| UI | `CR/ui/`, `CR/windows/` | hard singletons bound to `Dungeon.hero` |
| Sprites/effects | `CR/sprites/`, `CR/effects/`, `CR/tiles/FogOfWar.java` | visibility driven by `heroFOV` |
| Serialization | `SPD-classes/.../watabou/utils/{Bundle,FileUtils}.java`, `CR/GamesInProgress.java` | JSON+gzip, atomic writes |
| Engine | `SPD-classes/src/main/java/com/watabou/noosa/Game.java` | main loop, render thread |
| RNG | `SPD-classes/.../watabou/utils/Random.java` | global generator **stack** |
| Meta persistence | `CR/Rankings.java`, `CR/Badges.java`, `CR/journal/Journal.java`, `CR/SPDSettings.java` | separate files, outside run state |
| Platforms | `android/`, `desktop/`, `ios/`, `services/` | storage paths & platform support |

There is **zero existing networking code** (grep over core/ and SPD-classes/ confirms). Networking
belongs in a new package (e.g. `CR/multiplayer/`) — `core/` is platform-agnostic, and plain
`java.net` (TCP) is the dependency-free choice that works on Android + desktop.

---

## 2. The turn engine (`Actor`) — the most important subsystem

`CR/actors/Actor.java`:

- Every acting entity is an `Actor` with a float `time` (next-act timestamp) and a global static clock
  `Actor.now` (`:154`). `TICK = 1f` (`:39`). Actions spend time via `spend()`/`spendConstant()`
  (`:61-73`) which rounds near-integers to fix drift.
- Priority ladder for ties (`:48-56`): `VFX_PRIO(100) > HERO_PRIO(0) > BLOB_PRIO(-10) > MOB_PRIO(-20)
  > BUFF_PRIO(-30) > DEFAULT(-100)`. `Hero` uses `HERO_PRIO` (`Hero.java:194`), mobs `MOB_PRIO`,
  buffs `BUFF_PRIO`, blobs `BLOB_PRIO`, plus a long tail of one-off VFX actors (see §9.4).
- `Actor.process()` (`:244-326`) runs on the **actor thread** (started by `GameScene.update()`,
  `CR/scenes/GameScene.java:865-888`; render thread notifies it at most 60×/s). It repeatedly:
  1. picks the actor with the earliest `time` (ties → higher `actPriority`),
  2. sets `now = current.time`,
  3. **waits for the actor's sprite to finish its movement animation** (`:274-286`,
     `CharSprite.isMoving` + wait/notify, `CR/sprites/CharSprite.java:129, 824-835`),
  4. calls `act()`; a `false` return parks the thread (`:304-323` wait/notify on the thread object).
- **The hero is the pause point.** `Hero.act()` (`CR/actors/hero/Hero.java:831-929`): with no
  `curAction` and not resting it calls `ready()` and returns `false` → whole world freezes until
  input commits an action (`Hero.handle(cell)` sets `curAction`, `Hero.next()` resumes the loop).
  Input path: `CellSelector.select()` → `GameScene.defaultCellListener.onSelect()` →
  `Dungeon.hero.handle(cell)` + `Dungeon.hero.next()` (`CR/scenes/CellSelector.java:152-171`,
  `CR/scenes/GameScene.java:1750-1756`, `CR/scenes/CellSelector.java:415-417` for keyboard).
- The loop also stops when: `Game.switchingScene()`, thread interrupted, or
  **`Dungeon.hero == null || !Dungeon.hero.isAlive()`** (`:295-298`) — a hard-coded single-hero death
  check that must become "all heroes dead".

### Heads-ups for turn-based 2P

- **There is no "turn" object.** Turns are emergent from time ordering. Heroes act at
  `HERO_PRIO` when their time arrives; two heroes acting on the same timestamp are ordered only by
  `actPriority` (identical!) — `Actor.process()` tie-breaking at `:256-266` then falls back to
  iteration order of the `HashSet<Actor> all`, i.e. **nondeterministic**. You must define hero
  ordering yourself (e.g. hero id) and/or serialize hero turns explicitly (recommended, §10.3).
- `Actor.fixTime()` (`:170-192`) subtracts whole-number offsets from all actors and **directly reads
  `Dungeon.hero`** (`:188`, adding run duration statistics) — another single-hero ref in the engine itself.
- Float accumulation + rounding means two clients would drift; another reason not to do lockstep.
- The **time-freeze mechanics** (`TimekeepersHourglass.timeFreeze`, `Swiftthistle.TimeBubble`,
  `TimeStasis` — see `CR/actors/Char.java:1104-1119`) intercept `spendConstant` for *the hero* and
  queue delayed cell presses. With two heroes, one player's hourglass freezes the world for both —
  decide whether that's acceptable (it is thematically fine; charges drain twice as fast in wall-time
  terms since both players' actions process through the freeze).
- Buffs act as separate `Actor`s attached to chars (`CR/actors/Buff.java`); `Actor.add(Char)` also adds
  all its buffs (`Actor.java:348-355`). A second hero added at the right time gets identical treatment.

---

## 3. Global state & the single-hero problem (the big refactor)

Everything mutable lives in statics:

```java
// CR/Dungeon.java
public static Hero hero;              // :185  ← THE assumption
public static Level level;            // :186
public static int depth;  branch;     // :190-194
public static int gold;  energy;      // :199-200
public static QuickSlot quickslot;    // :188
public static long seed;  ...         // :210-214
// CR/actors/Actor.java
private static HashSet<Actor> all;    // :147
private static HashSet<Char> chars;   // :148
private static float now;             // :154
```

`Dungeon.init()` (`:233-287`) creates exactly one `Hero`, and `GamesInProgress.selectedClass`
(`CR/GamesInProgress.java:44`, a static!) initializes it (`Dungeon.java:286`).

### Quantified coupling

- `Dungeon.hero`: referenced in **~320 files** (hundreds of occurrences: `Mob.java` alone 55,
  `Char.java` 53, `DM300` 20, `Tengu` 20, `Preparation` 19, `YogDzewa` 17, `Talent` 17, …).
- `heroFOV`: referenced in **~175 files** (~250 occurrences).

### Strategy for the refactor

Rewriting 320 files to be hero-agnostic is not realistic. The pragmatic path used by successful SPD
multiplayer forks is:

1. **Introduce `Dungeon.heroes`** (array/list of the 1-2 heroes) while **keeping `Dungeon.hero` as a
   mutable pointer** meaning "the hero the local UI is currently attached to / the hero currently
   acting on this client".
2. Audit and fix the *simulation-critical* call sites to use explicit heroes instead of the pointer:
   - `Actor.process()` stop condition (`Actor.java:295`) → all heroes dead.
   - `Actor.fixTime()` statistics accounting (`Actor.java:188`).
   - `Actor.init()` (`:194-212`) → add *both* heroes to the actor set (currently only `Dungeon.hero`).
   - `Dungeon.switchLevel()` (`:464-518`) → position *both* heroes, displace mobs for both.
   - `Dungeon.observe()` (`:897-1019`) → per-hero FOV update (§6).
   - Mob `chooseEnemy()` / `surprisedBy()` / EXP/loot attribution (§5).
3. Everything else (UI, effects, GLog flavor) can keep reading the pointer as long as the pointer is
   only swapped at safe points (turn boundaries / UI events on the host, and it simply stays pointing
   at the local player's hero on the guest).

**Pointer-swap hazard**: code caches the hero across frames — e.g. `Hero.isAlive()` caches its
`Berserk` buff (`Hero.java:2271-2283`), `Char` damage paths reference `Dungeon.hero` repeatedly within
one call (`CR/actors/Char.java:364-1034`), `BuffIndicator.heroInstance` binds one char
(`CR/ui/BuffIndicator.java:143-164`). Only swap at turn boundaries, and prefer passing the acting
`Hero` explicitly in simulation code wherever a method is touched anyway.

### Per-hero vs shared state (design decisions to make early)

| State | Suggestion |
|---|---|
| HP/XP/inventory/belongings/talents | per hero |
| Hunger, Regeneration buffs | per hero (attached in `Hero.live()`, `Hero.java:446-452`) |
| Gold | shared pool (simplest; also affects shop prices/stealing) |
| Energy (Dwarf King / alchemy) | shared |
| Quickslots (`Dungeon.quickslot`, `CR/QuickSlot.java`) | per hero (placeholders already serialize per-run) |
| `Statistics`, `Notes`, chapters, quests | shared (already in `game.dat`); `Statistics.duration` via fixTime needs the fix above |
| Depth/branch | shared (see §8 for the "split party" question) |
| `visited`/`mapped` maps | shared union (existing arrays fine) |
| FOV | per hero (new arrays) |

---

## 4. Input & turn-commit path (where networking hooks in)

Current local flow (all verified):

1. Render thread pump: `Game.update()` → `GameScene.update()` (`CR/scenes/GameScene.java:837-933`)
   notifies the actor thread (≤60 Hz) whenever `!Actor.processing()`.
2. Input: `CellSelector` (tap/key/stick) → `select(cell, button)` gated by
   `enabled && Dungeon.hero.ready && !GameScene.interfaceBlockingHero()`
   (`CellSelector.java:152-154`). `GameScene.ready()` re-arms the selector
   (`GameScene.java:1642-1651`); `cellSelector.enable(Dungeon.hero.ready)` every frame (`:925`).
3. `Hero.handle(cell)` maps the cell to a `HeroAction` (Move/Attack/Interact/PickUp/Buy/OpenChest/
   Unlock/Mine/Alchemy/LvlTransition — `Hero.java:1877-1965`, `CR/actors/hero/HeroAction.java`).
4. `Hero.next()` (`Hero.java:2629`) releases the actor loop; `Hero.act()` executes the action,
   spends time, eventually `ready()`s again → world pauses for the next decision.

**This is the multiplayer seam.** A networked implementation can treat steps 3-4 as the unit of
commit:

- **Local player** on host: unchanged.
- **Remote player** (on host): receive command → apply to the remote `Hero` (set `curAction` the way
  `handle()` would) on the actor thread → `remoteHero.next()`. World proceeds.
- **Local player on guest**: UI unchanged, but instead of `handle()`, send the command to the host
  and *simulate/interpolate the outcome locally* (or optimistically preview while waiting for the
  authoritative result).

Commands should be data, not code: `{"player":0, "type":"cell", "cell":1234}` plus special types for
inventory actions (`{"type":"item", "itemID":…, "action":…}`), quickslot, "rest/wait", "cancel".
The existing `HeroAction` set is a good command vocabulary; item execution needs an item-id scheme
(items have no ids — see §9.2 heads-up).

---

## 5. Mob AI & targeting (the user's hunch — confirmed, with caveats)

`Mob.chooseEnemy()` (`CR/actors/mobs/Mob.java:275-442`) already does generic targeting:

- It selects from `Actor.chars()` / `Dungeon.level.mobs` based on `Char.Alignment`
  (`CR/actors/Char.java:183-188` — documented as "relative to the hero": `ENEMY/NEUTRAL/ALLY`).
- Enemy mobs hunt ALLY-aligned mobs (`:377-379`) — corrupted mobs, mirror images, earth guardian,
  ghost hero all work this way today, **including full AI-vs-AI fights** (also via
  `StoneOfAggression`, `Amok`, `recentlyAttackedBy` target-swapping at `:734-743, 1275-1290`).
- The only special-case for the player is `:382-384` (`enemies.add(Dungeon.hero)`).

So a second hero is *already* two-thirds supported:

- If both heroes carry `alignment == ALLY`, enemy mobs will target them from the ally-mob loop —
  but only if heroes are included in the candidate set. Fix: change `:382` to add every hero, and
  make the ally loop consider heroes as well (`:362-372`).
- `Hunting.act()` / `getCloser()` / `getFurther()` are fully generic (use `Actor.findChar`,
  `Dungeon.findPath`) — mob pathing treats heroes like any blocking char.

Caveats that DO need touching (all `Mob.java`):

- `surprisedBy()` (`:775-779`) — surprise attacks only register when `enemy == Dungeon.hero`.
  Generalize to "any hero".
- `defenseProc()` (`:708-764`) — sneaky-attack stats/badges and `Dungeon.hero.belongings
  .attackingWeapon()` reads assume the attacker is *the* hero.
- `destroy()` (`:831-873`) — EXP, badges, bestiary, Monk energy all go to `Dungeon.hero`; must credit
  the **actual killer** (the `src` chain is available at the kill site in `Char.attack`,
  `Char.java:569-586`).
- `die()`/`rollToDropLoot()` (`:876-987`) — loot chances depend on `Dungeon.hero`'s ring/talent
  (RingOfWealth, BountyHunter, ShardOfOblivion); decide per-hero influence or neutral drops.
- `damage()` (`:804-827`) — "assume the hero is hitting us" aggro for wand/spell/ability sources;
  needs the source hero.
- Alignment documentation (`Char.java:183`) should be updated: with two heroes the "relative to the
  hero" framing needs to become "relative to heroes as a faction".
- `Mob.holdAllies()`/`restoreAllies()` (`:1414-1509`) — static `heldAllies` list carrying allies
  across level switches; fine as-is but note it is global.

**Hero↔hero interactions exist already**: `Char.interact()` swaps two chars (`Char.java:244-309`),
`canInteract()`, and `heroShouldInteract()`. But `Hero.handle()` only creates `HeroAction.Interact`
for `Mob`s (`Hero.java:1904-1910`), and `CellSelector` only hit-tests the hero sprite and mobs
(`CellSelector.java:88-109`) — **tapping the other player's hero falls through to "move"**. Add the
ally hero to both hit-test and handle() (swap places is a natural co-op verb; the `ALLY_WARP` talent
code at `Char.java:270-282` shows the pattern).

---

## 6. Visibility & fog (largest non-hero-coupling refactor)

The codebase conflates three things in `Level.heroFOV` (`CR/levels/Level.java:159`, allocated at
`:331`, filled by `Dungeon.observe()` at `Dungeon.java:914` — *the hero's shadowcast result*):

1. **Local player's screen visibility** (fog rendering, sprite visibility, effect display),
2. **"Can the player see this" gameplay checks** (trap FX, `visibleFight` sounds,
   `Char.move()` sprite visibility `Char.java:1272-1274`, heap `seen` flag `Level.java:991`),
3. **Exploration tracking** (`visited`, `Level.java:153`, unioned in `Dungeon.observe()`).

Because ~175 files read `heroFOV`, the practical approach:

- Keep `level.heroFOV` as the **merged (OR) FOV of both heroes** for all existing sim/gameplay
  consumers — semantics become "visible to the party", which is correct for a co-op game (traps
  visibly trigger, sounds play, etc.).
- Add **per-hero FOV arrays** (e.g. `hero.personalFOV`) for: fog rendering per client, mob sprite
  visibility per client, and any "only I know this" feature.
- `FogOfWar` (`CR/tiles/FogOfWar.java`) is refreshingly parameterized already:
  `updateTexture(boolean[] visible, boolean[] visited, boolean[] mapped)` (`:172`) — its only caller
  hardwires `Dungeon.level.heroFOV` (`:322-326`). One FogOfWar instance per client fed with that
  client's arrays is straightforward.
- `Heap.seen` is currently a single bool per heap (`Level.java:991`) — with a party model, "seen"
  can stay merged (shared knowledge), which matches co-op intuition and keeps ItemSprite
  (`CR/sprites/ItemSprite.java:106-110, 326`) untouched.
- `GameScene.addMobSprite` sets initial visibility from `heroFOV` (`GameScene.java:1054-1060`) and
  `afterObserve()` refreshes it (`:1439-1448`) — these stay working with the merged array; per-client
  adjustments happen in the guest's mirror.

---

## 7. RNG & determinism (why not lockstep)

`SPD-classes/.../watabou/utils/Random.java`:

- A **global stack of `java.util.Random`** (`:37-74`); the base generator is *unseeded*, and the whole
  stack is `synchronized` static state.
- Level generation is seeded: `Dungeon.seedForDepth(depth, branch)` (`Dungeon.java:418-431`) derives a
  per-depth seed from the run seed; `Level.create()` pushes it (`Level.java:217`) — but the resulting
  layout also depends on run state consumed before `build()` (limited-drop rolls `Dungeon.java:224-253`,
  feelings, quest state, SpecialRoom queue seeded at `Dungeon.init` with `seed+1`, Generator pools).
  Same seed + same game-state path ⇒ same level (the debug skip-ahead at
  `CR/scenes/InterlevelScene.java:630-645` relies on it).
- Runtime randomness is **not** reproducible across clients: the base generator is unseeded,
  `Collections.shuffle(candidatePositions)` in `Mob.restoreAllies` uses system RNG
  (`Mob.java:1456`), `Collections.shuffle(passable)` in `Hero.reallyDie` (`Hero.java:2234`), and the
  actor thread's timing interleaves RNG draws. Also float time + `fixTime()` rebasing.

**Conclusion: do not attempt lockstep.** Use the host-authoritative model; the seeded levelgen is
still valuable because the *host* generates levels and ships the resulting level bundle to the guest
(§8, §9), and for reproducible test seeds.

---

## 8. Serialization, saves & sync strategy

### Existing system (all verified)

- `Bundle` = gzipped org.json JSON; `Bundlable` objects self-serialize with class-name tags
  (`__className`), `Bundle.addAlias` for renames. `Bundle.write/read` work on streams
  (`SPD-classes/.../utils/Bundle.java:487-554`) — **directly usable as a wire format** (little work to
  wrap a socket).
- Run state = `GamesInProgress.gameFile(slot)` (`game.dat` per slot: seed, hero, depth, gold,
  quests, Statistics, Notes, Actor.nextID … — `Dungeon.saveGame`, `CR/Dungeon.java:624-697`) + one
  `depthN[-branchM].dat` per visited floor (full `Level` incl. mobs, buffs, heaps, blobs, respawner —
  `Level.java:361-478`).
- `Dungeon.saveAll()` (`:706-717`) = `Actor.fixTime()` + both files; called on **pause**
  (`GameScene.onPause`, `GameScene.java:808-818`), **every level switch** (`Dungeon.java:511-517`),
  every `InterlevelScene` mode (`InterlevelScene.java:656, 681, 700, 719`), alchemy ops, menu-exit
  (`CR/windows/WndGame.java:105`). Death instead deletes the run (`Hero.reallyDie`,
  `Hero.java:2265`) and submits to `Rankings` via `Dungeon.fail` (`Dungeon.java:872-878`).
- Atomic crash-safe writes via `.spdtmp` (`FileUtils.java:206-213`).

### Using this for multiplayer

- **Protocol payloads**: `Bundle.write(bundle, outputStream, compressed=true)` gives you snapshots for
  free. Compress + chunk; TCP handles framing.
- **Full-sync points**: on join, on every level transition, on reconnect — simply ship `game.dat`-style
  bundle + current level bundle. The level bundle already contains *everything that matters on a
  floor* (mobs incl. their AI state/enemy ids, buffs, heaps, plants, traps, blobs, respawner).
- **Delta/event stream between syncs** (turn-based ⇒ low volume): emit events at natural sim points —
  `Char.move/attack/damage/die` already drive `sprite.*` calls; GLog lines; heap changes; buff
  add/remove. Either (a) intercept the existing `GameScene.add/addMob/discard/...` static entry points
  and mirror them as events, or (b) diff `Bundle` snapshots per committed action (turn-based makes
  this affordable: one snapshot per action, gzipped level bundles are small — map ints + mobs).
  Recommend (b) for correctness + periodic full resync + hash check; (a) for pretty animations.
- **Multiplayer save format**: extend `game.dat` with a `heroes` collection (keep the legacy `HERO`
  key for vanilla-save compatibility — write both). Depth files unchanged (heroes are not in them).
- **Version handshake**: clients must run identical builds (`Dungeon.version` is saved;
  `GamesInProgress.check` rejects < v2.5.4). Trivial to check at lobby time.
- **Reconnect = the save system**: host keeps the last synced bundle; guest reloads it and replays
  nothing. Turn-based + JSON snapshots make this trivial.
- Meta state (`Rankings`/`Badges`/`Journal`, `CR/Rankings.java`, `CR/Badges.java`,
  `CR/journal/Journal.java`) is per-machine; disable/aggregate for MP runs (e.g. don't submit MP runs
  to rankings; Badges from MP runs are a design choice). `Bones` (`CR/Bones.java`) — the
  previous-hero-remains mechanic — should be disabled for MP runs (its file is global).

---

## 9. Level lifecycle & inter-level transitions (two heroes on stairs)

- The **only** place level switching happens is `CR/scenes/InterlevelScene.java` (modes DESCEND /
  ASCEND / FALL / RETURN / CONTINUE / RESURRECT / RESET; `:81-91, 413-462, 622-813`), running on its
  own loading thread with `Actor.fixTime()` first (`:420`). It calls `Dungeon.newLevel()`,
  `Dungeon.loadLevel()`, `Dungeon.switchLevel(level, pos)`.
- `Dungeon.switchLevel` (`Dungeon.java:464-518`): sets `Dungeon.level`, positions the single hero
  (`hero.pos = pos` at `:481`), `Mob.restoreAllies`, `Actor.init()` (rebuilds actor set — **only
  adds `Dungeon.hero`**), `level.addRespawner()`, displaces a mob from the hero's arrival cell
  (`:493-503` — reusable pattern for two heroes), recomputes light, `observe()`, `saveAll()`.
- Transition entry: `Level.activateTransition()` (`Level.java:566-581`) — triggered when the hero
  steps onto a `LevelTransition` cell; `InterlevelScene.curTransition.destDepth/destType` determine
  arrival (`InterlevelScene.java:659-670`). Arrival cell is always a single `transition.cell()`,
  `fallCell()`, or saved pos.

**Multiplayer consequences:**

- `Dungeon.depth/branch` are shared. Decide the party rule early:
  - **Simplest (recommended)**: both heroes always travel together — when either steps onto a
    transition, both teleport (arrival cells = the two cells of a `StandardRoom` near the transition,
    or use `randomRespawnCell`-style placement; `Dungeon.switchLevel`'s mob-displacement loop is the
    template). This keeps depth/branch single-valued, keeps `level.locked` (boss seal) meaningful,
    and avoids modeling "a level that one player left behind".
  - Alternative (split party) requires per-player depth tracking and reworking
    `droppedItems`, `generatedLevels`, quest/respawn logic — expensive; avoid.
- Boss levels: `level.locked` blocks transitions (`interfloorTeleportAllowed`,
  `Dungeon.java:455-462`) — with co-op, one player entering the boss room seals both in. That is
  *probably desirable*, but verify each boss level's `occupyCell`/intro cinematic (they
  `runOnRenderThread` cinematics keyed to `Dungeon.hero`).
- `Chasm.heroFall` (`CR/levels/features/Chasm.java:99-129`) moves *the* hero a depth down — decide
  whether chasms split the party (easiest: yes, that's a fun co-op hazard; requires the split-party
  handling above… or make chasm falls also take both). Recommendation: fall both.
- `MobSpawner` respawn (`CR/actors/mobs/MobSpawner.java`) spawns at `randomRespawnCell(...) ≥ 12 tiles
  from hero` (`Level.java:742-764`) — must check distance from **both** heroes.
- `generatedLevels` / `levelHasBeenGenerated` (`Dungeon.java:293-295`) work unchanged (host only).

---

## 10. Proposed architecture

### 10.1 Topology & authority

```
┌────────── HOST (player A) ────────────┐        ┌────── GUEST (player B) ──────┐
│ vanilla simulation (Actor thread)     │        │ mirror scene (render only)   │
│ local input ──► Hero A command        │  TCP   │ local input ─► command msg   │
│ net rx ─► Hero B command              │◄─ TCP ─┤ net rx ─► snapshot/event     │
│ commit ─► snapshot diff / events      │──►     │ apply to mirror + fog        │
│ saveAll()-style full bundle on join/  │        │ (guest never simulates)      │
│ level change; periodic hash           │        │                              │
└───────────────────────────────────────┘        └──────────────────────────────┘
```

- **Host = authoritative.** All `Random` draws, AI, and combat happen on the host exactly as in
  single-player. Security is out of scope (friends).
- Guest sends only commands (tiny, rare — once per turn). Host sends: full world snapshot on
  join/level-change/reconnect, event/delta messages per committed action, and a "your turn" marker.
- Latency tolerance is high (turn-based): TCP, ~1 msg/turn, no need for UDP/tick clocks.
  Message envelope: `type`, `seq`, `playerId`, gzipped `Bundle` payload — reuse `Bundle.write(stream)`.

### 10.2 Hero & actor modeling

- Two `Hero` instances; `Dungeon.heroes = {heroA, heroB}`; `Dungeon.hero` kept as pointer
  (§3). Both added in `Actor.init()` and `switchLevel`.
- Hero enum/id: add a stable per-hero id (not `Actor.id()`, which is recycled per-load) serialized
  with the hero for addressing (`"player": 0/1`).
- `Actor.process()` stop conditions become: pause when any *ready* hero awaits input, stop only when
  **all** heroes are dead (`Actor.java:295`).
- Serialization: `game.dat` gains `heroes` (keep `HERO` key for compat, or migrate).

### 10.3 Turn scheduling with two heroes (recommended model)

Keep it boring and predictable:

1. World runs actors in time order as today (mobs, blobs, buffs, VFX).
2. When a hero's `act()` fires with no committed action: if it is *this client's* hero → park and
   wait for local input (unchanged); if it is the *remote* hero → park and wait for their command.
3. Both heroes act at the same timestamp → enforce deterministic order (hero id), so each player's
   action resolves strictly one after another, with mobs/blobs/buffs catching up between actions —
   this matches the requested "turn by turn, one after another".
4. While either hero is un-committed, the world is frozen (exactly the current single-player
   behavior) — no simultaneity problems, no race conditions.

Implementation-wise this is small: `Hero.act()`'s "no curAction → ready() → return false" branch
(`Hero.java:863-881`) already parks the loop; you only need (a) the remote-command delivery path,
(b) the tie-break in `Actor.process()` (order by `id()` when `actPriority` and `time` are equal),
(c) per-client "whose turn" UI, (d) rest/until-ready semantics generalized (rest currently loops
`TIME_TO_REST` per notification — `Hero.java:865-866`, `GameScene.java:826-828`).

### 10.4 Event/delta protocol (minimum viable set)

| Event | Source today (host) | Guest effect |
|---|---|---|
| hero action committed | `Hero.act()` result / `spend()` | animate remote hero sprite |
| char moved / attacked / damaged / died | `Char.move`, `Char.attack`, `Char.damage/die` (sprite calls) | mirror sprite ops |
| buff added/removed | `Char.add/remove(Buff)` | buff icons (or rely on snapshot) |
| heap/item changed | `Level.drop`, `Heap.pickUp/destroy` | item sprites |
| log line | `GLog.update` signal (`CR/utils/GLog.java:39-69`) | append to shared log |
| turn marker | hero-ready state | turn UI |
| level changed | `InterlevelScene` | apply new level snapshot |
| full resync | every N actions / on hash mismatch | replace world |

Practical shortcut: instead of authoring dozens of event types, **diff serialized snapshots**:
before each committed player action the host snapshots `game.dat`+`level` bundles; after the action
completes (hero `ready()` again), snapshot again and ship the diff (or the small per-entity diffs).
Turn-based cadence makes this viable (a level bundle is a gzip'd JSON of mostly int arrays —
compresses to tens of KB). Combine with targeted events purely for animations (moves/attacks you
already emit as sprite calls on the host; forward those sprite ops to the guest).

### 10.5 Guest rendering

The guest runs the **same `GameScene`** against mirrored state: sprites exist, fog uses the guest's
per-hero FOV arrays (§6), and the hero pane/inventory show the guest's own hero
(`Dungeon.hero` pointer = guest's hero — this is why the pointer strategy works so well: the entire
UI layer keeps working on both machines by pointing at "my" hero).

- Camera: each client follows *their own* hero (`HeroSprite.panFollow` → `Camera.main`,
  `CR/sprites/HeroSprite.java:105-132`; single `Camera.followTarget`, `SPD-classes/.../Camera.java:164`).
  Shared-screen spectating of the partner is a later feature (picture-in-picture second camera).
- Remote player's hero needs a `HeroSprite` wired like the local one minus the camera follow and
  minus `CharHealthIndicator`-vs-StatusPane assumptions (`CR/sprites/CharSprite.java:145-164`).

### 10.6 Meta systems

- `Rankings.submit` / `Dungeon.fail/win` (`Dungeon.java:872-888`): disable or tag MP runs
  (rankings records embed a full replayable `gameData` — MP replays don't fit the format).
- `Badges` (global + per-run local), `Journal`/`Catalog`/`Bestiary`: host-side only; guest can mirror
  for display or leave untouched.
- Daily runs: disable (seed/time coupling).
- Death: current flow is `Hero.die` → ankh check → `WndResurrect` → `reallyDie` → `deleteGame` +
  rankings. For MP: a dead player without ankh should become a spectator (their `Hero` stays out of
  `Actor.init()`; `Actor.process` stop-condition fix covers this) and revive rules become a design
  decision (e.g. resurrect at stairs for gold, or run ends when *both* dead — recommend the latter
  as the run-over condition).

---

## 11. Threading model & where the network lives

Current threads (all verified):

| Thread | Role |
|---|---|
| Render/main | libGDX loop (`Game.render/update`, `SPD-classes/.../noosa/Game.java:150-283`), scene graph, input events |
| "SHPD Actor Thread" | `Actor.process()` (`GameScene.java:865-882`); parks when hero awaits input |
| InterlevelScene thread | load/gen/save on transitions (`InterlevelScene.java:413-460`) |
| (ad-hoc) | audio, `Game.runOnRenderThread` = `Gdx.app.postRunnable` (`Game.java:306-313`) |

Rules already established by the codebase that the network layer must respect:

- **Sim mutations happen on the actor thread; scene/UI mutations on the render thread** —
  `Game.runOnRenderThread` marshalling is used all over act() paths (windows: `Hero.java:1027,
  2173, 2253`; cinematics: boss levels; `GameScene.flash` `GameScene.java:1463-1479`).
- `GameScene.show(Window)` is not actor-thread-safe by itself (`GameScene.java:1352-1374`);
  `GameScene.toDestroy` queue exists precisely because of this (`GameScene.java:824, 927-932`).
- Cross-thread flags exist (`Emitter.freezeEmitters`, blob arrays read by `BlobEmitter` while
  `Blob.act()` mutates them).

**New "network thread"**: plain socket read/write loop.

- Inbound (guest→host): parse command → **marshal onto the actor thread** (simplest: a
  `ConcurrentLinkedQueue<Command>` drained at the top of the parked-wait path in
  `GameScene.update()` before notifying the actor thread — mirroring the existing notify pattern).
- Inbound (host→guest): snapshot/event → apply on render thread (scene-safe), sim-shaped payloads
  kept in a staging queue applied inside `GameScene.update()`.
- Outbound: snapshots are built on the actor thread at safe points (same discipline as
  `saveAll()` which already runs from `onPause`/transitions) then handed to the socket thread.

---

## 12. UI singletons inventory (per-player panes needed)

From the audit (file:line in the audit notes):

- `TargetHealthIndicator.instance` (`CR/ui/TargetHealthIndicator.java:29`)
- `QuickSlotButton.instance[]` + static `lastTarget/targetingSlot` (`CR/ui/QuickSlotButton.java:46-55`);
  `execute(Dungeon.hero)` at `:99-102`, FOV check at `:324`
- `BuffIndicator.heroInstance/bossInstance` (`CR/ui/BuffIndicator.java:143-144, 406-420`)
- `AttackIndicator.instance`, static `updateState()` reads `Dungeon.hero.visibleEnemies()` (`CR/ui/AttackIndicator.java:47, 117-120, 195-207`)
- `Toolbar.instance` — rest/search buttons drive `Dungeon.hero` (`CR/ui/Toolbar.java:74, 201-313`)
- `InventoryPane.instance` — hardcoded `Dungeon.hero.belongings` (`CR/ui/InventoryPane.java:70, 294, 386-391, 566`)
- `ActionIndicator.instance` (`CR/ui/ActionIndicator.java:38`)
- `StatusPane` — constructed once against `Dungeon.hero` (`CR/ui/StatusPane.java:128, 170, 293-374`; created at `GameScene.java:452-459`)
- Windows: `WndHero` (hardcoded hero), `WndBag` (static factories over `Dungeon.hero.belongings`),
  `WndTradeItem`, `WndUseItem`, `WndResurrect` (static `instance` gate used by `Dungeon.saveAll`!)
- Hero-first tap resolution in `CellSelector` (`:91-97`)

Since each client renders only *their own* hero's UI, most of this works unchanged on both machines
once `Dungeon.hero` points at the local hero. What genuinely needs work: a small remote-hero HUD
(HP/name of the partner), and making the above singletons owned by the scene (they are re-created
per GameScene anyway).

---

## 13. Work breakdown (suggested phases)

**P0 — De-static the hero (hotseat-ready refactor, no networking)**
1. `Dungeon.heroes` + pointer swap; add second hero in `Dungeon.init()`/`Actor.init()`/`switchLevel()`
   (`Dungeon.java:233-287, 464-518`; `Actor.java:194-212`).
2. Fix engine stop conditions: `Actor.process` (`:295`), `fixTime` (`:188`).
3. Mob AI: `chooseEnemy` hero set, `surprisedBy`, killer-credited EXP/loot (`Mob.java:275-442, 775-779, 831-987`).
4. Per-hero FOV arrays + merged `heroFOV`; `Dungeon.observe()` (`Dungeon.java:897-1019`).
5. Input: second hero selectable/interactable (`CellSelector.java:88-109`, `Hero.java:1904-1910`);
   per-hero `curAction/ready` (already instance fields — mostly works).
6. Save format: `heroes` array in `game.dat` (backward compatible read).
7. Local hotseat test rig (two windows / input switch) to validate all of the above before any wire protocol.
   *This phase is >70% of the real work and is entirely local-testable.*

**P1 — Command protocol & host authority (single-process loopback first)**
1. Command types (cell/item/quickslot/wait/cancel) mirroring `HeroAction` + item ids.
2. Turn manager: commit order, "waiting for player X" state, tie-break by id.
3. Snapshot diffing on the existing Bundle pipeline (serialize → compare → ship).

**P2 — Real transport (TCP, `java.net`), lobby/UI**
- Host/join via direct IP (SPDSettings already persists strings — `CR/SPDSettings.java` /
  `GameSettings` flush immediately), version handshake, connect from TitleScene/StartScene clone.
- Guest runs `GameScene` in mirror mode: loads snapshot bundles through the existing
  `loadGame`/`loadLevel` code paths (in-memory, no disk), applies deltas.

**P3 — Polish**
- Partner HUD, turn indicator, per-client fog, level-transition choreography (both-players-descend),
  death/revive rules, disable rankings/dailies/bones for MP runs, reconnect via full snapshot,
  hash check + full resync on mismatch.

---

## 14. Gotcha checklist (heads-ups)

1. **`Actor` iteration order** — `all` is a `HashSet`; anything tie-broken only by set order is
   nondeterministic across machines/JVMs. Tie-break explicitly (id) wherever two heroes can coincide.
2. **`Actor.process` stops when `Dungeon.hero` dies** (`Actor.java:295`) — must become "all heroes
   dead", else the world freezes while the partner still plays.
3. **`WndResurrect.instance` gates `saveAll()`** (`Dungeon.java:707`) and resurrection rewinds the
   level (`InterlevelScene.RESURRECT`, `InterlevelScene.java:749-803`) — semantics under MP need
   deciding (who resurrects whom).
4. **EXP/loot/aggro attribution** assumes `Dungeon.hero` (§5) — killing credit must follow the actual
   attacker, not the global pointer.
5. **`surprisedBy` / sneak mechanics** only acknowledge `Dungeon.hero` (`Mob.java:775-779`).
6. **Hero's `fieldOfView` IS `level.heroFOV`** (`Hero.java:834`) — two heroes cannot share it;
   `checkVisibleMobs()`, `mindVisionEnemies`, `AttackIndicator`, auto-pickup gating
   (`Hero.java:1922-1926`) all read it.
7. **Effects parent to `Dungeon.hero.sprite.parent`** (e.g. `CR/effects/Wound.java:92`,
   `CR/effects/Surprise.java:92`) — on the host this is fine (one scene), on the guest ensure the
   mirrored scene has the same group structure.
8. **`Char.move()` toggles sprite visibility by `heroFOV` for every non-pointer char**
   (`Char.java:1272-1274`) — the partner's hero sprite will flicker with *your* FOV unless
   special-cased.
9. **Sprite motion blocks the actor loop** per-Char via wait/notify (`Actor.java:274-286`) —
   already scales to two heroes, but make sure remote-hero commands aren't committed while their
   sprite is mid-tween (the actor loop handles it; just don't double-commit).
10. **Time freeze items** (`TimekeepersHourglass`, `Swiftthistle`, `TimeStasis`) intercept the acting
    char's `spendConstant` (`Char.java:1104-1119`) — one player's hourglass now affects the shared
    world; decide whether guests even see frozen-time sprite states (`Mob.updateSpriteState`,
    `Mob.java:647-653`).
11. **Resting** (`Hero.resting`, `GameScene` notify cap 60 Hz) is an animation-pacing hack — for the
    remote hero, "rest until ready" should be one command ("rest N turns" or "rest until
    interrupted"), not 60Hz-stepped local animation.
12. **`GamesInProgress.selectedClass`** is a static used by `Dungeon.init()` (`Dungeon.java:286`) —
    hero B's class must be plumbed differently (from lobby data).
13. **Quest NPCs are hero-centric** (Ghost/Wandmaker/Blacksmith/Imp dialogs via
    `runOnRenderThread` windows, `CR/actors/mobs/npcs/*`) — they'll open on whichever machine hosts
    the sim; the *other* player just sees the outcome. Acceptable for v1; the quests' `Quest.store/
    restore` bundles already live in `game.dat`.
14. **`Actor.ids` / `nextID`** are saved/restored (`Actor.java:216-226`) — keep the host as the sole
    id authority; never let the guest allocate ids.
15. **`Bundle` class-name coupling** — both clients must run byte-identical class sets (no hot code
    mismatch mid-run); enforce via the version handshake. `Bundle.addAlias` shows the intended
    compat mechanism for renames.
16. **`Statistics.duration`** accumulates in `fixTime` via `Dungeon.hero` presence
    (`Actor.java:188-190`) — with two heroes it double-counts unless adjusted.
17. **`Dungeon.scalingDepth()`/`AscensionChallenge`** and various challenges read `Dungeon.hero` for
    global modifiers (`Dungeon.java:447-453`) — decide if challenge modifiers apply per hero or are
    disabled for MP (recommend: fixed challenge set chosen at lobby, applied to the run).
18. **`WndGame` "exit to menu"** calls `saveAll()` then switches scene (`CR/windows/WndGame.java:105`)
    — a player leaving mid-run needs a protocol answer (host continues solo? run pauses?).
19. **Blobs are O(area) per turn on the actor thread** (`CR/actors/blobs/Blob.java:111-207`) — with
    two players the *content* per turn roughly doubles; fine, but avoid also snapshotting huge blob
    `cur[]` arrays in every delta (they're already trimmed in their bundle format).
20. **`GamesInProgress` slot model** — a MP run shouldn't occupy the local single-player slots
    blindly; either a dedicated slot or a `multiplayer` flag in `game.dat` (it already stores
    `daily`/`customSeedText` etc. — same mechanism, `Dungeon.java:630-633`).

---

## 15. Open design questions (decide before P1)

1. **Co-op only, or allow hero-vs-hero damage?** Alignment model currently makes heroes ALLY;
   friendly-fire is trivially possible in the sim (`Char.attack` doesn't check target faction, and
   `Mob.defenseSkill` returns 0 vs ally hero). Suggest: disallow targeting the partner (no
   `HeroAction.Attack` against heroes) for v1.
2. **Shared gold & item scarcity**: limited drops (`Dungeon.LimitedDrops`, `Dungeon.java:104-180`)
   are tuned for one hero (2 POS per 5 floors, 3 SOU…). With two heroes, either halve per hero or
   leave as-is for a harder-but-fair co-op. Explicitly decide.
3. **Turn granularity**: strict alternation (recommended, §10.3) vs. simultaneous commit with
   interleaved resolution (more "fair", more complex — requires both clients to submit before world
   advance; doable at the same pause point but with a two-phase commit).
4. **Death rules** (§10.6) and **run-over conditions**.
5. **Split-party**: recommend hard-shared depth (§9); confirm nobody wants co-op-from-different-
   floors for v1.
6. **Spectating**: can a client pan to the partner? (needs partner FOV data or "reveal" events;
   cheap alternative: partner HUD + bestiary-level info only).

---

## 16. Key file quick-reference

| File | Why it matters |
|---|---|
| `CR/actors/Actor.java` | turn loop, priorities, pause semantics, stop conditions |
| `CR/actors/hero/Hero.java` | act/ready/busy/next, handle(), death, EXP, talents |
| `CR/actors/mobs/Mob.java` | AI states, chooseEnemy targeting, aggro, EXP/loot |
| `CR/actors/Char.java` | base char, alignment, attack/damage pipeline, interact/swap |
| `CR/Dungeon.java` | all global state, init/save/load, observe(), switchLevel |
| `CR/levels/Level.java` | heroFOV, mobs/heaps/blobs/plants/traps, occupyCell/pressCell, transitions |
| `CR/scenes/GameScene.java` | actor thread lifecycle, sprite wiring, fog updates, input plumbing |
| `CR/scenes/CellSelector.java` | all input → cell selection gating |
| `CR/scenes/InterlevelScene.java` | every level transition mode |
| `SPD-classes/.../utils/Bundle.java` | serialization (JSON+gzip) — reuse as wire format |
| `CR/GamesInProgress.java` | save slot/file layout |
| `SPD-classes/.../utils/Random.java` | RNG generator stack (why not lockstep) |
| `CR/tiles/FogOfWar.java` | parameterized fog texture (per-client ready) |
| `CR/ui/*` | singletons needing per-player treatment |
| `CR/sprites/CharSprite.java` | motion-blocking handshake with actor thread |