foxygit / doom Log in
commit 6efd10a4818883d8d7568bbdfe56910553c8eee3
Author:     jens <jens.se@icloud.com>
AuthorDate: Sat Aug 22 00:11:41 2026 +0200
Commit:     jens <jens.se@icloud.com>
CommitDate: Sat Aug 22 00:11:41 2026 +0200

    Add a multiplayer lobby, reachable from the menu

    Options > Multiplayer leads through the normal episode/skill selection
    into a new waiting-room screen instead of starting play immediately:
    it shows the phone-join QR code (now always available, not just under
    -qrstart) and a live list of who's joined so far, colored to match
    their eventual splitscreen tile border. The host (whoever's at the
    keyboard) is seated automatically; others can join by phone or, once
    i_joystick.c supports more than one local input device, another
    keyboard zone/gamepad. Pressing Enter starts the game with whoever has
    joined by then.

    Implementation:
    - G_DeferedInitLobby (g_game.c/h) is G_DeferedInitNew's lobby-flavored
      sibling: starts the level with nobody in-game (not even the host),
      sets lobby_active, then G_DoNewGame seats the host once the level has
      actually loaded via the new G_AddNextLocalPlayer helper (also used to
      de-duplicate the existing debug KEY_JOIN_PLAYER hotkey's slot-picking
      logic).
    - d_main.c folds lobby_active into the existing waiting_for_players
      rendering suppression and draws the lobby screen (D_DrawLobby) in
      place of the real view for as long as it's true.
    - G_Responder checks lobby_active first, before HU_Responder/
      ST_Responder/AM_Responder - chat/cheats/automap don't make sense yet
      while waiting, and would otherwise have first claim on the same
      Enter keypress used to start.
    - I_WebInputDrawJoinQR no longer gates on the -qrstart flag itself
      (existing callers already gate it externally where that's wanted),
      so the lobby can request a QR code without needing -qrstart passed.

    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---
 TODO.md               |   2 -
 src/doom/d_main.c     |  88 +++++++++++++++++++++++++++++-
 src/doom/g_game.c     | 145 ++++++++++++++++++++++++++++++++++++++------------
 src/doom/g_game.h     |  22 ++++++++
 src/doom/i_webinput.c |  10 ++--
 src/doom/m_menu.c     |  66 +++++++++++++++++++++--
 6 files changed, 286 insertions(+), 47 deletions(-)

diff --git a/TODO.md b/TODO.md
index c2e45409..1e42f9ff 100644
--- a/TODO.md
+++ b/TODO.md
@@ -31,8 +31,6 @@ and unstructured wish list of features and improvements. The bug tracker
   - Finale/cast-crawl screen (f_finale.c) is still native-resolution and
     too small, same root cause as the now-fixed menu/intermission/status
     bar (see m_menu.c, wi_stuff.c, st_stuff.c/st_lib.c, i_video.h).
-  - Add a menu item for starting in splitscreen mode (currently only
-    reachable via -splitscreen on the command line).
   - A player dying and respawning resets the whole game for everybody
     instead of just that player.
   - Losing connection or reloading the phone controller page spawns a new
diff --git a/src/doom/d_main.c b/src/doom/d_main.c
index d23b9cfa..6a6269a5 100644
--- a/src/doom/d_main.c
+++ b/src/doom/d_main.c
@@ -55,6 +55,7 @@
 #include "i_endoom.h"
 #include "i_input.h"
 #include "i_joystick.h"
+#include "i_swap.h"
 #include "i_system.h"
 #include "i_timer.h"
 #include "i_video.h"
@@ -154,6 +155,78 @@ void D_ProcessEvents (void)



+//
+// D_DrawLobbyText
+// Draws str at real screen coordinates x,y using the small message
+// font (hu_font) - same font ST_DrawMiniHud (st_stuff.c) uses for the
+// same reason: this is UI drawn directly in real screen space (chosen
+// freely here, not inherited from vanilla's 320x200 layout), not
+// scaled-up native-space content like the menu/status bar.
+//
+static void D_DrawLobbyText (int x, int y, const char *str)
+{
+    int i;
+    unsigned char c;
+    int cw;
+
+    for (i = 0 ; str[i] != '\0' ; i++)
+    {
+        c = toupper(str[i]);
+
+        if (c < HU_FONTSTART || c > HU_FONTEND)
+        {
+            x += 8;
+            continue;
+        }
+
+        cw = SHORT(hu_font[c - HU_FONTSTART]->width);
+        V_DrawPatch(x, y, hu_font[c - HU_FONTSTART]);
+        x += cw;
+    }
+}
+
+//
+// D_DrawLobby
+// The "Multiplayer" options menu item's waiting room (see
+// G_DeferedInitLobby, m_menu.c): shows a join QR so phones can connect
+// (I_WebInputInit's server is always running - see D_DoomMain - so no
+// extra setup is needed here, just drawing the code), plus a list of
+// who's joined so far, in the same colors R_RenderSplitViews borders
+// their tile with once play actually starts. Drawn instead of the real
+// view for as long as lobby_active is true (see D_Display).
+//
+static void D_DrawLobby (void)
+{
+    int i;
+    int y;
+    char buf[32];
+    int qr_size;
+
+    V_DrawFilledBox (0, 0, SCREENWIDTH, SCREENHEIGHT, 0);
+
+    qr_size = SCREENHEIGHT - 80;
+    I_WebInputDrawJoinQR (40, 40, qr_size, qr_size);
+
+    D_DrawLobbyText (SCREENWIDTH/2 + 20, 40, "MULTIPLAYER LOBBY");
+    D_DrawLobbyText (SCREENWIDTH/2 + 20, 60, "SCAN THE QR CODE TO JOIN BY PHONE");
+
+    y = 100;
+    for (i = 0 ; i < MAXPLAYERS ; i++)
+    {
+        if (!playeringame[i])
+            continue;
+
+        V_DrawFilledBox (SCREENWIDTH/2 + 20, y + 2, 16, 16,
+                          R_PlayerBorderColor (i));
+        M_snprintf (buf, sizeof(buf), "PLAYER %d", i + 1);
+        D_DrawLobbyText (SCREENWIDTH/2 + 44, y, buf);
+        y += 24;
+    }
+
+    D_DrawLobbyText (SCREENWIDTH/2 + 20, SCREENHEIGHT - 40,
+                      "PRESS ENTER TO START");
+}
+
 //
 // D_Display
 //  draw current display, possibly wiping it from the previous
@@ -201,7 +274,14 @@ boolean D_Display (void)
         for (i = 0; i < MAXPLAYERS; i++)
             if (playeringame[i])
                 numactive++;
-        waiting_for_players = (numactive == 0) && I_WebInputNoLocalEnabled ();
+        // lobby_active (see g_game.c's G_DeferedInitLobby) folds into
+        // waiting_for_players so it gets the exact same treatment below
+        // (ST_Drawer/AM_Drawer/HU_Drawer/border all skipped, real
+        // rendering suppressed) regardless of how many players have
+        // joined so far - unlike the plain -nolocal case, which only
+        // waits while there are exactly zero.
+        waiting_for_players = lobby_active
+            || ((numactive == 0) && I_WebInputNoLocalEnabled ());
         show_split = splitscreen
             || (numactive == 1 && I_WebInputQrEnabled ());
     }
@@ -308,7 +388,11 @@ boolean D_Display (void)
     // draw the view directly
     if (gamestate == GS_LEVEL && !automapactive && gametic)
     {
-	if (waiting_for_players)
+	if (lobby_active)
+	{
+	    D_DrawLobby ();
+	}
+	else if (waiting_for_players)
 	{
 	    // No player exists yet to render a view from (see the
 	    // waiting_for_players computation above) - show a fullscreen
diff --git a/src/doom/g_game.c b/src/doom/g_game.c
index a188ff22..34b19ecd 100644
--- a/src/doom/g_game.c
+++ b/src/doom/g_game.c
@@ -954,10 +954,25 @@ static void SetMouseButtons(unsigned int buttons_mask)
 // G_Responder
 // Get info needed to make ticcmd_ts for the players.
 //
-boolean G_Responder (event_t* ev)
-{
+boolean G_Responder (event_t* ev)
+{
+    // Multiplayer lobby (see G_DeferedInitLobby, m_menu.c's
+    // "Multiplayer" options item, d_main.c's lobby screen): checked
+    // before anything else, including HU_Responder/ST_Responder/
+    // AM_Responder below, none of which make sense yet (chat, cheat
+    // codes, the automap) while still waiting in the lobby and which
+    // would otherwise have first claim on this same Enter keypress.
+    // The host presses Enter once everyone who's joining locally or by
+    // phone has joined, to stop waiting and start playing.
+    if (lobby_active)
+    {
+        if (ev->type == ev_keydown && ev->data1 == KEY_ENTER)
+            lobby_active = false;
+        return true;
+    }
+
     // allow spy mode changes even during the demo
-    if (gamestate == GS_LEVEL && ev->type == ev_keydown
+    if (gamestate == GS_LEVEL && ev->type == ev_keydown
      && ev->data1 == key_spy && (singledemo || !deathmatch) )
     {
 	// spy mode
@@ -1031,26 +1046,17 @@ boolean G_Responder (event_t* ev)
         next_weapon = 1;
     }

-    // Local splitscreen: bring the next available player slot into the
-    // game on the fly, or take the most recently joined one back out
-    // (never slot 0 - that is the primary/host player).
+    // Debug-only: bring the next available local player slot into the
+    // game on the fly, or take the most recently joined one back out.
+    // Not menu-exposed - see the lobby_active check at the top of this
+    // function for the discoverable way to start a splitscreen game.
     if (ev->type == ev_keydown && ev->data1 == KEY_JOIN_PLAYER)
     {
-        int slot;
-        for (slot = 0; slot < MAXPLAYERS; slot++)
-        {
-            if (G_AddLocalPlayer (slot))
-                break;
-        }
+        G_AddNextLocalPlayer ();
     }
     else if (ev->type == ev_keydown && ev->data1 == KEY_LEAVE_PLAYER)
     {
-        int slot;
-        for (slot = MAXPLAYERS - 1; slot >= 1; slot--)
-        {
-            if (G_RemoveLocalPlayer (slot))
-                break;
-        }
+        G_RemoveLastLocalPlayer ();
     }

     switch (ev->type)
@@ -1691,6 +1697,40 @@ boolean G_RemoveLocalPlayer (int playernum)
     return true;
 }

+// G_AddNextLocalPlayer / G_RemoveLastLocalPlayer
+// Thin wrappers around G_AddLocalPlayer/G_RemoveLocalPlayer that pick
+// which slot to add/remove without the caller needing to know slot
+// numbering. G_AddNextLocalPlayer is also used to bring the host into
+// a freshly-started lobby (see G_DoNewGame) - both this and the debug
+// KEY_JOIN_PLAYER/KEY_LEAVE_PLAYER hotkeys in G_Responder share a
+// single definition of "next"/"last" this way.
+boolean G_AddNextLocalPlayer (void)
+{
+    int slot;
+
+    for (slot = 0; slot < MAXPLAYERS; slot++)
+    {
+        if (G_AddLocalPlayer (slot))
+            return true;
+    }
+
+    return false;
+}
+
+boolean G_RemoveLastLocalPlayer (void)
+{
+    int slot;
+
+    // Never slot 0 - that is the primary/host player.
+    for (slot = MAXPLAYERS - 1; slot >= 1; slot--)
+    {
+        if (G_RemoveLocalPlayer (slot))
+            return true;
+    }
+
+    return false;
+}
+
 // G_SetRemoteInputState
 // Feeds a remotely-sourced (e.g. phone) held-button bitmask into slot
 // localslot's gamekeydown[] row, using the same key identities
@@ -2176,39 +2216,65 @@ void G_DoSaveGame (void)
 // Can be called by the startup code or the menu task,
 // consoleplayer, displayplayer, playeringame[] should be set.
 //
-skill_t	d_skill;
-int     d_episode;
-int     d_map;
-
+skill_t	d_skill;
+int     d_episode;
+int     d_map;
+
+boolean lobby_active = false;
+static boolean lobby_pending = false;
+
 void
 G_DeferedInitNew
 ( skill_t	skill,
   int		episode,
-  int		map)
-{
-    d_skill = skill;
-    d_episode = episode;
-    d_map = map;
-    gameaction = ga_newgame;
-}
+  int		map)
+{
+    d_skill = skill;
+    d_episode = episode;
+    d_map = map;
+    gameaction = ga_newgame;
+}
+
+void
+G_DeferedInitLobby
+( skill_t	skill,
+  int		episode,
+  int		map)
+{
+    lobby_pending = true;
+    G_DeferedInitNew (skill, episode, map);
+}


 void G_DoNewGame (void)
 {
     int i;
+    boolean starting_lobby = lobby_pending;
+
+    lobby_pending = false;

     demoplayback = false;
     netdemo = false;
     netgame = false;
     deathmatch = false;

-    // Starting a fresh game from the menu drops any netgame players
-    // back to just the local one - but local splitscreen players are
-    // not netgame players, so leave their slots alone (this used to be
-    // hardcoded to indices 1-3 for vanilla's MAXPLAYERS==4; generalized
-    // here since MAXPLAYERS is now larger).
-    if (!splitscreen)
+    if (starting_lobby)
     {
+        // A fresh lobby always starts completely empty, regardless of
+        // whatever splitscreen slots happened to be active in whatever
+        // game was running before - G_AddNextLocalPlayer below brings
+        // the host back in once the level has loaded.
+        for (i = 0; i < MAXPLAYERS; i++)
+            playeringame[i] = false;
+        splitscreen = false;
+    }
+    else if (!splitscreen)
+    {
+        // Starting a fresh game from the menu drops any netgame players
+        // back to just the local one - but local splitscreen players are
+        // not netgame players, so leave their slots alone (this used to be
+        // hardcoded to indices 1-3 for vanilla's MAXPLAYERS==4; generalized
+        // here since MAXPLAYERS is now larger).
         for (i = 1; i < MAXPLAYERS; i++)
             playeringame[i] = false;
     }
@@ -2218,6 +2284,15 @@ void G_DoNewGame (void)
     nomonsters = false;
     consoleplayer = 0;
     G_InitNew (d_skill, d_episode, d_map);
+
+    if (starting_lobby)
+    {
+        // G_InitNew loads the level synchronously (ends in
+        // G_DoLoadLevel), so gamestate is already GS_LEVEL here -
+        // G_AddNextLocalPlayer can spawn the host immediately.
+        lobby_active = true;
+        G_AddNextLocalPlayer ();
+    }
     gameaction = ga_nothing;
 }

diff --git a/src/doom/g_game.h b/src/doom/g_game.h
index 192e1f5a..632d6edf 100644
--- a/src/doom/g_game.h
+++ b/src/doom/g_game.h
@@ -38,6 +38,19 @@ void G_InitNew (skill_t skill, int episode, int map);
 // but a warp test can start elsewhere
 void G_DeferedInitNew (skill_t skill, int episode, int map);

+// Like G_DeferedInitNew, but starts with nobody in-game yet (not even
+// the local host) and sets lobby_active - see m_menu.c's "Multiplayer"
+// options item. The host is added automatically once the level loads
+// (see G_DoNewGame); anyone else (phones, via the QR the lobby shows -
+// see d_main.c) can join before the host presses Enter to start.
+void G_DeferedInitLobby (skill_t skill, int episode, int map);
+
+// True from G_DeferedInitLobby's game finishing loading until the host
+// presses Enter - see d_main.c, which shows the lobby screen (QR code,
+// joined-player list) and suppresses normal rendering for as long as
+// this is true, and G_Responder, which watches for the Enter press.
+extern boolean lobby_active;
+
 void G_DeferedPlayDemo (const char* demo);

 // Can be called by the startup code or M_Responder,
@@ -79,6 +92,15 @@ boolean G_AddLocalPlayer (int playernum);
 // running game. Returns false if they weren't in-game.
 boolean G_RemoveLocalPlayer (int playernum);

+// G_AddLocalPlayer/G_RemoveLocalPlayer for whichever slot is next to
+// join/last to leave, without the caller needing to know slot
+// numbering - used by the debug KEY_JOIN_PLAYER/KEY_LEAVE_PLAYER
+// hotkeys (G_Responder), and G_AddNextLocalPlayer also brings the host
+// into a freshly-started lobby (see G_DoNewGame). Same return value
+// meaning as the functions they wrap.
+boolean G_AddNextLocalPlayer (void);
+boolean G_RemoveLastLocalPlayer (void);
+
 // Remote (e.g. phone) input for one local player slot: a held-button
 // bitmask (feeds the same gamekeydown[] state a local splitscreen
 // keyboard zone would - see GetLocalKeybinds in g_game.c) plus a
diff --git a/src/doom/i_webinput.c b/src/doom/i_webinput.c
index f612708e..7c25f3f9 100644
--- a/src/doom/i_webinput.c
+++ b/src/doom/i_webinput.c
@@ -632,15 +632,19 @@ static int FindRedPaletteIndex(void)

 // I_WebInputDrawJoinQR
 // Draws a join QR code, fit and centered, into the screen-buffer
-// rectangle (x, y, w, h) - meant for whatever empty splitscreen grid
-// cell R_RenderSplitViews hands it. No-op if there's nothing to encode.
+// rectangle (x, y, w, h). No-op if there's nothing to encode. Doesn't
+// gate on I_WebInputQrEnabled (the -qrstart flag) itself - that only
+// controls whether R_RenderSplitViews/the plain -nolocal wait screen
+// draw one automatically; both already check it themselves before
+// calling this, and other callers (e.g. the menu-triggered "Multiplayer"
+// lobby - see d_main.c) legitimately want a QR without -qrstart.
 void I_WebInputDrawJoinQR(int x, int y, int w, int h)
 {
     int size, quiet, scale, total, px_size, ox, oy;
     int mx, my;
     static int red_index = -1;

-    if (!I_WebInputQrEnabled())
+    if (cached_join_url[0] == '\0')
         return;

     if (!qr_matrix_ready)
diff --git a/src/doom/m_menu.c b/src/doom/m_menu.c
index a600d585..d1ac897b 100644
--- a/src/doom/m_menu.c
+++ b/src/doom/m_menu.c
@@ -198,6 +198,7 @@ static void M_MusicVol(int choice);
 static void M_ChangeDetail(int choice);
 static void M_SizeDisplay(int choice);
 static void M_Sound(int choice);
+static void M_Multiplayer(int choice);

 static void M_FinishReadThis(int choice);
 static void M_LoadSelect(int choice);
@@ -334,6 +335,7 @@ menu_t  NewDef =
 enum
 {
     endgame,
+    multiplayer,
     messages,
     detail,
     scrnsize,
@@ -347,6 +349,13 @@ enum
 menuitem_t OptionsMenu[]=
 {
     {1,"M_ENDGAM",	M_EndGame,'e'},
+    // No WAD graphic for this - name is deliberately empty, so the
+    // generic per-item draw loop in M_Drawer skips it (it already
+    // guards on name[0], see the "name[0] && W_CheckNumForName" check)
+    // and M_DrawOptions below draws a text label instead, same
+    // approach as the "Messages: ON"/"Graphic Detail: HIGH" dynamic
+    // labels already do for their own state.
+    {1,"",	M_Multiplayer,'p'},
     {1,"M_MESSG",	M_ChangeMessages,'m'},
     {1,"M_DETAIL",	M_ChangeDetail,'g'},
     {2,"M_SCRNSZ",	M_SizeDisplay,'s'},
@@ -898,6 +907,12 @@ void M_DrawNewGame(void)
     V_DrawPatchDirect(54, 38, W_CacheLumpName(DEH_String("M_SKILL"), PU_CACHE));
 }

+// Set by M_Multiplayer before entering the (otherwise ordinary)
+// episode/skill selection menus, so M_ChooseSkill/M_VerifyNightmare -
+// their common final step - know to start a lobby (see
+// G_DeferedInitLobby) instead of jumping straight into a normal game.
+static boolean want_lobby = false;
+
 void M_NewGame(int choice)
 {
     if (netgame && !demoplayback)
@@ -914,6 +929,30 @@ void M_NewGame(int choice)
 	M_SetupNextMenu(&EpiDef);
 }

+//
+// M_Multiplayer
+// Same episode/skill selection as M_NewGame, but flags the resulting
+// game to start as a lobby (see want_lobby/M_StartNewOrLobbyGame below)
+// - nobody spawns until the host presses Enter, so others can join by
+// phone (or another local keyboard zone/gamepad, once i_joystick.c
+// supports more than one - see g_game.c's TODOs) first.
+//
+void M_Multiplayer(int choice)
+{
+    if (netgame && !demoplayback)
+    {
+	M_StartMessage(DEH_String(NEWGAME),NULL,false);
+	return;
+    }
+
+    want_lobby = true;
+
+    if (gamemode == commercial || gameversion == exe_chex)
+	M_SetupNextMenu(&NewDef);
+    else
+	M_SetupNextMenu(&EpiDef);
+}
+

 //
 //      M_Episode
@@ -925,12 +964,25 @@ void M_DrawEpisode(void)
     V_DrawPatchDirect(54, 38, W_CacheLumpName(DEH_String("M_EPISOD"), PU_CACHE));
 }

+static void M_StartNewOrLobbyGame(skill_t skill, int episode, int map)
+{
+    if (want_lobby)
+    {
+        want_lobby = false;
+        G_DeferedInitLobby(skill, episode, map);
+    }
+    else
+    {
+        G_DeferedInitNew(skill, episode, map);
+    }
+}
+
 void M_VerifyNightmare(int key)
 {
     if (key != key_menu_confirm)
 	return;
-
-    G_DeferedInitNew(nightmare,epi+1,1);
+
+    M_StartNewOrLobbyGame(nightmare,epi+1,1);
     M_ClearMenus ();
 }

@@ -941,8 +993,8 @@ void M_ChooseSkill(int choice)
 	M_StartMessage(DEH_String(NIGHTMARE),M_VerifyNightmare,true);
 	return;
     }
-
-    G_DeferedInitNew(choice,epi+1,1);
+
+    M_StartNewOrLobbyGame(choice,epi+1,1);
     M_ClearMenus ();
 }

@@ -972,7 +1024,11 @@ void M_DrawOptions(void)
 {
     V_DrawPatchDirect(108, 15, W_CacheLumpName(DEH_String("M_OPTTTL"),
                                                PU_CACHE));
-
+
+    // No WAD graphic for this item - see its blank name in OptionsMenu.
+    M_WriteText(OptionsDef.x, OptionsDef.y + LINEHEIGHT * multiplayer + 4,
+                "MULTIPLAYER");
+
     V_DrawPatchDirect(OptionsDef.x + 175, OptionsDef.y + LINEHEIGHT * detail,
 		      W_CacheLumpName(DEH_String(detailNames[detailLevel]),
 			              PU_CACHE));