commit 9a219b07bfdeb6d95a77fa5791e1583c3860148d
Author: jens <jens.se@icloud.com>
AuthorDate: Tue Aug 25 17:44:24 2026 +0200
Commit: jens <jens.se@icloud.com>
CommitDate: Tue Aug 25 17:44:24 2026 +0200
Add a porting guide for reimplementing the splitscreen fork elsewhere
Documents each of the fork's seven main features (N-player splitscreen
rendering, HUD scaling fallout, multi-listener audio, per-local-player
input routing, the raised player cap, phone/QR join, and the lobby):
what problem each solves, what we actually did (with file/function
references), and - since most of the low-level renderer/HUD tricks are
specific to Chocolate Doom's vanilla fixed-point architecture and won't
apply as-is to a modern port - what to look for instead in a different
codebase. Flags which pieces (multi-listener audio, the phone/QR join
protocol, and the "route new input through existing per-player slots"
pattern) are the most portable and worth prioritizing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---
PORTING_GUIDE.md | 521 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 521 insertions(+)
diff --git a/PORTING_GUIDE.md b/PORTING_GUIDE.md
new file mode 100644
index 00000000..d7ebf965
--- /dev/null
+++ b/PORTING_GUIDE.md
@@ -0,0 +1,521 @@
+# Porting Guide: Infinite Splitscreen → Modern Source Ports
+
+This document explains what this fork changed to turn Chocolate Doom (a
+vanilla-accurate, single-player-shaped 1993 codebase) into a 25-player
+local-splitscreen-plus-phone-join engine, and how to carry each idea into
+a different, modern Doom source port (GZDoom, Zandronum, Odamex, EDGE,
+dsda-doom, etc.).
+
+**How to use this document.** Modern ports differ hugely from each other
+and from Chocolate Doom - some already have hardware-accelerated,
+resolution-independent rendering; some already have real network
+multiplayer with dynamic join; none (as far as this fork's author
+verified) have phone/QR-code joining. So this guide is deliberately not
+a line-by-line patch. For each feature it gives: the problem, exactly
+what we did here (concrete enough to reimplement the *idea*), and what
+to go look for in your target engine before assuming you need the same
+fix. Sections are independent - read only the ones relevant to what
+you're porting.
+
+Every file/function reference below is against this repo; check it with
+`git log --oneline 353cf500..HEAD` (the fork point) and
+`git show <commit>` for the reasoning behind any specific change - the
+commit messages carry a lot of the "why," not just the "what."
+
+---
+
+## At a glance
+
+| # | Feature | Portability | Why |
+|---|---|---|---|
+| 1 | N-player splitscreen rendering | Low - vanilla-specific plumbing | Fixed-point renderer tricks; modern ports likely already solve this differently (many already support splitscreen with a real 3D pipeline) |
+| 2 | HUD/UI scaling fallout from #1 | Low - only exists because of #1 | Skip entirely if your renderer isn't fixed-point/vanilla-authored |
+| 3 | Multi-listener positional audio | **Medium-High** - concept ports cleanly | "Loudest of all active listeners wins" is architecture-agnostic |
+| 4 | Per-local-player input routing | **Medium** - concept ports well, specifics don't | The "N independent input slots feed N ticcmds, phones are just another slot" idea is the reusable part |
+| 5 | Player-count ceiling raised | Low - vanilla-specific constant/array audit | Modern ports are usually not hardcoded to 4 already |
+| 6 | Phone/QR-code join system | **High** - nearly engine-agnostic | Plain TCP+HTTP+WebSocket server bolted onto the side; the only engine-specific part is the last-mile "write these bits into input state" |
+| 7 | Multiplayer lobby | **Medium** - concept ports well | State machine and UI ideas transfer; exact code doesn't |
+
+If you're porting to an engine that already has real splitscreen and/or
+real network multiplayer, sections 1, 2, and 5 are probably irrelevant -
+your target already solved "more than one simultaneous player" better
+than a 1993 renderer ever could. **Sections 3, 4, and 6 are where the
+actual reusable ideas live.**
+
+---
+
+## 1. N-player splitscreen rendering
+
+**Problem solved:** render up to 25 independent player views into one
+frame, tiled in a grid, without touching vanilla's per-column/per-span
+drawing code.
+
+**What we did (vanilla/fixed-point-specific):**
+
+- Chocolate Doom's internal framebuffer was fixed at 320x200
+ (`SCREENWIDTH`/`SCREENHEIGHT` in `src/i_video.h`). We raised it to
+ 640x400 and introduced a **second** pair of constants, `ORIGWIDTH`/
+ `ORIGHEIGHT` = 320/200, meaning "the resolution all the fixed-point
+ math and WAD-authored graphics assume." Splitscreen tiles get real
+ extra pixels; nothing is squeezed into the old 320x200 budget.
+- View-size computation was split into a cheap and an expensive half:
+ - `R_SetupViewSizeTables` (expensive: rebuilds angle/light/scale
+ tables) runs once per *tile-size* change, i.e. once whenever the
+ active player count changes the grid shape.
+ - `R_RepositionBuffer` (cheap: just rewrites `viewwindowx/y` and the
+ `columnofs[]`/`ylookup[]` row/column pointer tables) runs once per
+ *player per frame*, right before that player's `R_RenderPlayerView`
+ call, to slide the same-sized view window to that player's tile.
+ - This split is the one idea worth keeping even in a completely
+ different renderer: **separate "recompute projection/scale tables"
+ (rare) from "move the render target" (every tile, every frame)** if
+ your engine doesn't already do this by virtue of using a real GPU
+ viewport/scissor rect per player.
+- Grid layout: `cols = ceil(sqrt(N))`, `rows = ceil(N/cols)` - near-
+ square. We picked `MAXPLAYERS = 25` specifically because it's the
+ largest count with **no wasted grid cell** (5x5). An aspect-correct
+ sub-rect is fit and centered inside each grid cell (letterbox/
+ pillarbox), because several vanilla formulas (weapon-sprite
+ placement, `BASEYCENTER`) implicitly assume something close to
+ 4:3 and were never generalized to arbitrary tile aspect ratios.
+- Whole-screen clear before drawing tiles: with `cols*rows > N` (e.g. 5
+ players -> 3x2 grid, one empty cell) or `SCREENWIDTH/cols` leaving a
+ rounding sliver, per-tile drawing alone leaves stale pixels from a
+ previous frame's differently-shaped layout.
+- Per-player color identity: sprite translation tables were widened
+ from 3 (vanilla, players 2-4) to `MAXPLAYERS-1`, cycling through 9
+ base palette ramps once player count exceeds that. A single
+ `R_PlayerBorderColor(playernum)` function is shared by the tile
+ border, the lobby roster swatch, and (implicitly) sprite color, so a
+ player's identity is visually consistent everywhere.
+- A genuinely load-bearing widening: `visplane_t`'s `top[]`/`bottom[]`
+ arrays went from `byte` to `unsigned short`, because a 400px-tall
+ view exceeds what a `byte` y-coordinate can hold - this silently
+ truncated before being caught by a visible glitch, not a crash.
+
+**Porting guidance:** if your target already renders multiple viewports
+(most modern ports with any splitscreen support already use real GPU
+viewport rects, not framebuffer subdivision), you almost certainly
+don't need any of the above - it exists purely to work around a 1993
+fixed-point renderer that assumes exactly one full-screen view. What
+*is* worth carrying over regardless of renderer architecture:
+
+- The **near-square grid layout formula** and the decision to reserve
+ the largest "no wasted cell" player count if you want a similarly
+ clean grid.
+- The **aspect-correct-fit-then-letterbox** idea, if your engine has
+ any per-tile UI/HUD math that assumes a particular aspect ratio.
+- Auditing anywhere your target's per-player color/identity system
+ assumes a small, fixed player count (see §5).
+
+---
+
+## 2. UI/HUD scaling consequences
+
+**Problem solved:** menu, status bar, intermission, and finale screens
+are all authored in native 320x200 pixel positions; once the real
+buffer became 640x400 (§1), every one of those draw calls only filled
+the top-left quadrant.
+
+**What we did:** rather than rewrite every file's layout math, we added
+scaled patch-blit primitives and, in three files, shadowed the vanilla
+draw function names with macros that redirect to the scaled version -
+zero call-site changes needed in the bulk of each file. The two traps
+worth flagging for **any** situation where you scale a legacy
+fixed-position UI:
+
+1. **A "draw scaled" primitive that multiplies position by scale too**
+ is fine until you need to enlarge just the *content* without moving
+ it further from the origin (we hit this trying to make one menu
+ label bigger than its neighbors - the bigger scale factor also
+ dragged it further from (0,0) than intended). Decide up front
+ whether your scaled-draw primitive takes native or real-screen
+ coordinates, and provide **both** a "position and size scale
+ together" and a "position is real, size scales independently"
+ variant.
+2. **The macro-redirect trick only catches draw calls, not layout
+ math that happens to reuse the same width/height constant** for
+ centering, line-wrapping, or bounds-checking. Every such use has to
+ be found and fixed individually (search for the old
+ width/height constant's name across the file, not just draw calls).
+
+Also worth carrying over generally: **anywhere code reserves real
+screen-space for a legacy element (e.g. status bar height) using a
+constant that used to mean both "real pixels" and "native pixels" at
+once**, and only one of those meanings changed - that's an easy silent
+bug (ours: a status-bar-height constant used for border-fill layout
+stayed native-sized after the status bar itself became scaled, clipping
+its own top edge).
+
+**Porting guidance:** if your target has a resolution-independent UI
+system (vector/scalable, or already renders HUD at a chosen resolution
+independent of the 3D viewport), skip this section entirely - it's
+purely a consequence of #1's fixed-point-buffer approach.
+
+---
+
+## 3. Multi-listener positional audio
+
+**Problem solved:** vanilla computes pan/volume relative to
+`players[consoleplayer]`, the only listener that can exist. With
+several simultaneously-alive local players potentially far apart in the
+map, a sound near a non-consoleplayer must still be audible.
+
+**The algorithm (`S_BestListenerParams`, engine-agnostic):**
+
+```
+for each active player i:
+ if sound origin == player[i]'s body position:
+ this_volume = base_volume # at-listener, full volume, centered pan
+ else:
+ this_volume, this_pan = normal-distance-based-falloff(player[i], origin)
+ if out of range: skip this listener
+ if this_volume > best_volume so far:
+ best = (this_volume, this_pan)
+return best, or "inaudible" if no listener could hear it at all
+```
+
+Applied both when a sound starts and every frame for already-playing
+positional sounds (so a sound fades in/out correctly as the *nearest*
+listener among several changes, e.g. one player walks away while
+another walks up).
+
+**Porting guidance:** this is the most directly reusable idea in the
+whole fork, independent of renderer/netcode architecture - it's pure
+audio-mixing logic. If your target already has a full 3D audio engine
+(most modern ports do, often via OpenAL or similar with real
+per-source/per-listener attenuation), check whether it supports
+multiple simultaneous listeners natively; if it only exposes "set the
+one listener position," you'll need this same "compute against every
+active local player, take the loudest result" wrapper around whatever
+distance/attenuation function your audio backend already provides. Do
+budget for it being an O(sources x listeners) scan per frame if your
+target has far more concurrent sound sources than Doom's fixed channel
+count.
+
+---
+
+## 4. Per-local-player input routing
+
+**Problem solved:** multiple simultaneous local players sharing one
+keyboard need independent input state and independent per-tic command
+generation, and remote input sources (phones, §6) need to slot into
+that same pipeline without a parallel code path.
+
+**The architectural idea (the single most reusable thing in this
+fork):** every place vanilla read input from *global* state
+(`gamekeydown[]`, joystick axis globals, etc.) became an *array indexed
+by local-player slot*, and the ticcmd-building function
+(`G_BuildTiccmd`) took an explicit slot parameter instead of implicitly
+meaning "the player." A new key-routing function
+(`GetKeyLocalSlot`) maps an incoming raw key event to a slot by
+checking whether it matches that slot's configured key zone (slot 0 =
+the user's normal configurable bindings; slot 1 = a fixed WASD+QEFGCV
+zone in this fork - deliberately disjoint from vanilla's default
+bindings so the two zones can't physically collide, though this is a
+static assumption, not defended against a user rebinding into a
+collision).
+
+The per-tic driver (`d_loop.c`'s `BuildNewTic` here) then loops over
+every active local slot and calls the same ticcmd-builder once per
+slot, writing each into the same per-tic-per-player command buffer the
+networking layer already used for *actual* network players. **This is
+the key insight**: local splitscreen players are fed through the exact
+same "one ticcmd per player per tic" data structure a real network
+peer would use - splitscreen isn't a special case, it's "local players
+who happen to skip the network round-trip."
+
+**Then §6's phones reuse the exact same array-of-input-state, not a
+separate path**: a phone's WebSocket bit-flags are written directly
+into that slot's `gamekeydown[]` row (via the same key-mapping struct
+slot 0/1 keyboard input would use) before that slot's normal
+`G_BuildTiccmd` call runs. No phone-specific ticcmd construction exists
+anywhere - from the tic-builder's point of view, a phone is
+indistinguishable from a keyboard zone.
+
+**Porting guidance:**
+
+- If your target is already a real network-multiplayer engine (most
+ modern ports are), it likely **already has a "per-player input
+ state -> per-player ticcmd" abstraction for network play** - the
+ generalizable move is to make *local* splitscreen players (and later,
+ phones) look like additional instances of whatever that abstraction
+ already is, rather than inventing a second, splitscreen-specific
+ input path alongside it. Look for wherever your target currently
+ assumes "the local player" is a singleton (often a single global
+ `consoleplayer`/`localplayer` index) feeding into ticcmd generation,
+ and see whether it can become a list instead.
+- Vanilla-specific details that won't transfer: the specific WASD+QEFGCV
+ key zone, the `gamekeydown[MAXPLAYERS][NUMKEYS]` array shape, and the
+ mouse-only-drives-slot-0 restriction (most modern ports already
+ support multiple physical gamepads better than vanilla's single
+ `SDL_Joystick*` did, which this fork explicitly left un-generalized -
+ slots 2+ have no bound physical input device at all in this fork,
+ phones or nothing).
+- Config/persistence plumbing (widening a fixed-size per-player
+ keybind array and adding matching config-file entries for the new
+ slots) is a mechanical, engine-specific chore, not a design decision.
+
+---
+
+## 5. Player-count ceiling raised (4 -> 25)
+
+**Problem solved:** vanilla hardcodes `MAXPLAYERS = 4` pervasively.
+
+**What actually breaks when you raise it, checklist for any engine:**
+
+- **Bitfields sized for a small player count.** Our sprite-translation
+ color index was a 2-bit field (0-3); widening `MAXPLAYERS` alone
+ doesn't widen the field - it silently wraps/collides. Search for any
+ field-width comment or mask (`& 0x3`, `>> N` with a small N) near
+ player-color/identity code.
+- **Fixed-size arrays whose *element type* can't hold the new range.**
+ Our `visplane_t.top[]/bottom[]` (`byte` -> `unsigned short`, see §1)
+ is the general shape of this bug: not the array *length*, but a
+ value stored *in* it silently overflowing once some other dimension
+ (screen height, in our case) grew alongside the player count.
+- **Loops that iterate "the first 4 players" by masking an index**,
+ not by checking `MAXPLAYERS`. We found vanilla's monster
+ target-acquisition (`P_LookForPlayers`) masks its scan index with
+ `&3` - unchanged in this fork, meaning **monster AI here still only
+ ever notices players in slots 0-3**, a real, easy-to-miss limitation
+ that doesn't crash or look obviously wrong, it just quietly leaves
+ most players AI-invisible. Any engine you port "more players" to
+ needs an explicit audit for this shape of bug, not just a recompile
+ after bumping the constant.
+- **Zero-players edge cases vanilla never needed to handle.** Once it's
+ possible to have a level running with *no* players yet (a pre-game
+ lobby, or every player only phone-joinable), any vanilla loop that
+ assumes "there's always at least one player in slots 0-3" and relies
+ on that to eventually hit a loop-exit condition can spin forever the
+ first time it runs with zero players in-game. (This bit us in
+ `P_LookForPlayers` itself - fixed with an explicit early-out.)
+- **Content that only ships assets for 4 players** (HUD face
+ graphics, intermission portraits) can't simply be widened - either
+ cap the *display* of such content at 4 while still fully simulating
+ all N players (our approach for the intermission stats screen), or
+ supply new assets/fallback rendering for the extra slots.
+- **Savegame/demo binary formats** are versioned/fixed-size in most
+ classic-derived engines. We did not audit or exercise this in this
+ fork (no save/reload testing was done with >4 players) - treat it as
+ a real open question for your target, not something safe to assume
+ "just works" because the in-memory arrays got bigger.
+
+**Porting guidance:** most modern ports are not hardcoded to exactly 4
+already (many support 8+ for real network games), so this section is
+mostly a reminder of *what to check* rather than a specific fix to
+port - the checklist above is the transferable part.
+
+---
+
+## 6. Phone/QR-code join system
+
+**This is the most portable, most self-contained piece of the whole
+fork**, and probably the most valuable one to bring to a modern port
+regardless of whether that port already has good splitscreen, because
+few if any Doom source ports have phone-as-controller joining at all.
+
+**Explicitly scoped as a spike in the original code**: plain `ws://`
+(no TLS), no authentication, a hand-rolled 1-2-byte-per-frame binary
+protocol, hard-capped concurrent connections. Fine on a LAN for a
+party; do not expose it to the open internet as-is.
+
+**Architecture (fully engine-agnostic - this is just a small embedded
+web server):**
+
+1. A background thread `bind()`/`listen()`s a plain TCP socket on a
+ fixed port, at engine startup.
+2. Each accepted connection reads one raw HTTP request. If it's a
+ plain GET (someone opening the join URL in a browser), respond with
+ an embedded HTML+JS page (see below) and close. If it has
+ `Upgrade: websocket`, perform the standard RFC 6455 handshake
+ (SHA-1 the `Sec-WebSocket-Key` + the fixed magic GUID, base64,
+ reply `101 Switching Protocols`) and keep the connection open.
+3. The phone page renders two twin-stick virtual joysticks (movement:
+ dead-zone + four-cardinal-direction bitmask; turn: dead-zone +
+ continuous analog axis) plus fire/use buttons, and sends a tiny
+ binary WebSocket frame - `[buttonBitmask, turnAxisByte]` - every
+ time either changes, plus a fixed keepalive interval regardless of
+ change (so "no news" can be told apart from "everything released").
+4. Server-side, once per game tic, each connected phone's latest
+ `[bitmask, turnAxis]` is decoded and fed into **whatever your
+ engine's per-local-player input state already is** (§4) - for us,
+ that meant setting the same per-slot key-down bits and treating the
+ turn axis as if it were an analog joystick's x-axis. This is the
+ only genuinely engine-specific step; everything before it is a
+ generic embedded web server.
+5. **Reconnect/token system**, so a phone's WiFi hiccup or page reload
+ doesn't kick the player out of the game: the join page generates a
+ persistent per-device random token on first load (stored in
+ `localStorage`) and sends it as a URL query parameter on every
+ WebSocket connection attempt. Server-side, disconnecting a phone
+ does **not** immediately remove that player from the game - it's
+ marked "owner disconnected" with a timestamp, keyed by *player slot*
+ (not by TCP connection, since the connection is what just died).
+ On any new connection presenting a matching token within a timeout
+ window (we used 30 seconds), the same in-game player body resumes
+ being driven by the new connection with no despawn/respawn. Only
+ after the timeout with no matching reconnect does the player
+ actually get removed.
+6. **QR code display**: at startup, enumerate local network interfaces
+ and pick the most likely LAN-reachable address (private ranges
+ preferred, e.g. `192.168.*` over `172.*` over anything else),
+ build the join URL, and render it as a QR code using any vendored
+ QR-encoding library (we used a small public-domain one -
+ `qrcodegen.c/h` in this repo - not analyzed further here since it's
+ a generic, swappable dependency, not fork-specific logic).
+
+**Porting guidance:** steps 1-3, 5, and 6 above should port to nearly
+any engine with almost no changes - they don't touch game state at all
+until step 4. The one thing to design carefully for your target is
+exactly what step 4 writes into: if your engine's local-input
+abstraction from §4 doesn't exist yet, phone-join is a good forcing
+function to build it, since "another local input slot" is the cleanest
+place for a phone to plug into. Avoid the temptation to give phones
+their own separate ticcmd-construction path "just for now" - that's
+how you end up maintaining two input pipelines instead of one.
+
+A note on scope: nothing about this system requires splitscreen at
+all. An engine with real network multiplayer but no phone support
+could adopt just this section (feeding a phone's input into a network
+client's local input state) without touching rendering at all.
+
+---
+
+## 7. Multiplayer lobby
+
+**Problem solved:** a pre-game screen where local players and phones
+can join (or not) before the level starts, instead of every slot being
+decided at launch time via command-line flags.
+
+**The state machine (engine-agnostic shape):**
+
+- A `lobby_active` flag gates almost everything else. While true:
+ input handling short-circuits at the very top of the responder chain
+ (before chat/cheat/automap handling, which don't make sense
+ pre-game and would otherwise steal the same keys) and recognizes
+ exactly two actions: **"join locally"** (the user's own configured
+ interact/use key adds them to the next free local slot) and
+ **"start"** (only takes effect if at least one player - local or
+ phone - has actually joined; otherwise it's a no-op, since starting
+ with zero players means nobody to render a view for).
+- Critically, **the local player at the keyboard is not auto-seated**.
+ Nobody is special-cased into slot 0 just because they started the
+ lobby from the menu - they join the same way a phone does, by taking
+ an explicit action. (An earlier version of this fork *did*
+ auto-seat the host, and had to be changed - see commit `97211d8b` -
+ once it became clear "player 1 is always local" contradicts letting
+ anyone join by phone instead.)
+- Phones join concurrently through the exact same "add next local
+ player" function a keyboard press would call (§4/§6) - the lobby
+ doesn't have a separate phone-join code path either.
+- Rendering: a simple live roster (one row per currently-joined
+ player, with a color swatch matching that player's eventual
+ in-splitscreen identity) plus a join QR code, redrawn every frame
+ since the roster can change on any frame a phone connects.
+- **There is no separate "commit the roster and start" step.** The
+ moment the "start" condition is met, `lobby_active` simply flips
+ false, and the exact same live player-list array the lobby was
+ reading from is what the rest of the engine already keys off for
+ "who's in the game" - normal gameplay rendering/input just starts
+ reading it as a live game state instead of a pre-game one. Players
+ can continue joining or leaving *after* the level starts through the
+ identical machinery, since nothing about it was lobby-specific to
+ begin with.
+
+**A genuine gameplay bug this surfaced, worth checking on any target
+with an analogous concept**: vanilla's death-respawn logic assumes
+"single player" and "not a network game" are the same condition, and
+reloads the *entire level* when the one-and-only player dies. Local
+splitscreen is also "not a network game" but very much *not*
+single-player - if your target has any similar "am I alone" check
+gating a destructive reset-everything path, verify it's actually
+checking "is there more than one simultaneous player," not "is this a
+network game."
+
+**Porting guidance:** if your target already has real network
+multiplayer, it likely already has *some* pre-game lobby/ready-check
+concept - the reusable ideas here are (a) treating "joined this lobby"
+and "connected to the game" as the same underlying list rather than
+two lists that need reconciling at start time, and (b) not
+special-casing the local/host player as automatically present. The
+specific rendering (QR code, color swatches) is cosmetic and should
+match whatever your target's existing menu/UI system looks like.
+
+---
+
+## Cross-cutting gotchas (apply to any porting effort)
+
+1. **Two "screen size" concepts must be kept separate** when scaling a
+ fixed-point/fixed-resolution engine: the actual framebuffer
+ resolution vs. the resolution any given piece of legacy math was
+ calibrated for. Every *additive* formula that used to read "the
+ screen size" as a stand-in for "the native size" needs individual
+ auditing when those two diverge (a sky-texture offset, a sprite's
+ vertical center, a reserved-UI-height constant); *multiplicative*
+ ratio-based formulas derived from view size tend to self-correct.
+2. **A "draw/scale" primitive that scales position along with content
+ is a trap** the moment you need to enlarge something without moving
+ it. Decide up front whether your scaling primitives take native or
+ real coordinates, and provide both variants rather than letting
+ call sites guess wrong.
+3. **Widening a bitfield or array bound doesn't fix truncation by
+ itself** - anywhere a "small N" assumption is baked into a
+ fixed-width type (not a named constant), raising the *logical*
+ limit elsewhere won't surface the truncation until it manifests as
+ a visible or logical glitch, not a compile error or crash.
+4. **Padding/alignment fields adjacent to arrays that rely on
+ deliberate off-by-one out-of-bounds access must be widened in
+ lockstep with the array's element type** - a classic-engine
+ idiom (writing to `array[-1]` or `array[len]` on purpose, relying on
+ it landing in a padding field) breaks silently if the padding's
+ width doesn't track the array's element width.
+5. **Loops that iterate "up to N players" by masking an index**
+ instead of checking the actual player-count constant don't error
+ when you raise the limit - they just quietly stop noticing the
+ extra players. These are the easiest class of bug to miss because
+ nothing crashes or looks obviously broken; specifically audit any
+ AI/targeting/scoring code, not just rendering and input.
+6. **Reuse the existing local-input plumbing for new input sources
+ instead of building a parallel path.** This is the single idea
+ worth carrying into any target regardless of architecture: local
+ splitscreen players, and later phones, were both implemented as
+ "just another slot" in the same input-state array and the same
+ per-tic command-building loop a real network peer or a keyboard
+ already used - not as separate systems that happen to feed the same
+ game. Every time this fork was tempted to special-case a new input
+ source, going back and finding the existing generic mechanism
+ instead paid off; the phone-join system in particular has *zero*
+ phone-specific game-state-mutation code anywhere - it only ever
+ writes into the same per-slot arrays a keyboard zone would.
+
+---
+
+## Suggested porting order
+
+If adopting more than one section, this is roughly the order of
+decreasing portability / increasing payoff-per-effort, based on the "at
+a glance" table above:
+
+1. **§3 (multi-listener audio)** - small, self-contained, immediately
+ useful for any target with more than one simultaneous local player,
+ independent of whether you do anything else in this list.
+2. **§6 (phone/QR join)**, targeting whatever your engine's existing
+ local-input abstraction is (build one per §4's idea first if it
+ doesn't exist) - highest novelty-to-effort ratio, since essentially
+ no other source port has this.
+3. **§4 (per-local-player input routing)** - only worth doing
+ standalone if your target doesn't already support local splitscreen
+ and you're adding it, or if you're doing #2 and need the
+ abstraction it plugs into.
+4. **§7 (lobby)** - once #2/#4 exist, this is mostly UI/state-machine
+ work, not deep engine surgery.
+5. **§5 (raise player cap)** - do this early if you're touching any of
+ the above anyway, since it's more of an audit checklist than a
+ feature, but don't bother if your target already supports enough
+ players.
+6. **§1/§2 (fixed-point splitscreen rendering + UI scaling)** - only
+ relevant if your target's renderer is architecturally similar to
+ vanilla Doom's (fixed 320x200-style buffer, fixed-point column/span
+ drawers). Most modern ports have already solved "multiple
+ viewports" with a real 3D pipeline and don't need any of this.