foxygit / doom Log in
commits tags

/PORTING_GUIDE.md · 27.98 KB

raw

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

#FeaturePortabilityWhy
1N-player splitscreen renderingLow - vanilla-specific plumbingFixed-point renderer tricks; modern ports likely already solve this differently (many already support splitscreen with a real 3D pipeline)
2HUD/UI scaling fallout from #1Low - only exists because of #1Skip entirely if your renderer isn't fixed-point/vanilla-authored
3Multi-listener positional audioMedium-High - concept ports cleanly"Loudest of all active listeners wins" is architecture-agnostic
4Per-local-player input routingMedium - concept ports well, specifics don'tThe "N independent input slots feed N ticcmds, phones are just another slot" idea is the reusable part
5Player-count ceiling raisedLow - vanilla-specific constant/array auditModern ports are usually not hardcoded to 4 already
6Phone/QR-code join systemHigh - nearly engine-agnosticPlain TCP+HTTP+WebSocket server bolted onto the side; the only engine-specific part is the last-mile "write these bits into input state"
7Multiplayer lobbyMedium - concept ports wellState 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.