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));