foxygit / doom Log in
commit 71b98c5e44008fb2b6c5d39a2c207eebceffb58c
Author:     jens <jens.se@icloud.com>
AuthorDate: Sat Aug 22 09:13:16 2026 +0200
Commit:     jens <jens.se@icloud.com>
CommitDate: Sat Aug 22 09:13:16 2026 +0200

    Reclaim a phone's player on reconnect instead of spawning a new one

    A dropped connection (WiFi hiccup) or a page reload used to despawn
    the player immediately and let the next join grab whatever slot
    happened to be free - usually not the same one, so from the player's
    perspective they just lost their body and got a fresh one.

    The join page now generates a per-device token on first load (stored
    in localStorage, so it survives a reload) and sends it as a query
    string on the WebSocket URL; it also auto-reconnects a few times a
    second while disconnected instead of leaving the player to notice and
    reload manually. Server-side, a disconnect no longer removes the
    player right away - it just frees the TCP connection slot and starts
    a 30-second clock (RECLAIM_TIMEOUT_TICS). A reconnect with a matching
    token resumes controlling the same still-in-game player with no
    despawn/respawn; if nobody reclaims it in time, it's removed the same
    way it always was.

    Also fixes a latent connection-slot leak noticed while in this code:
    a plain (non-WebSocket) HTTP request never cleared its `active` flag,
    permanently consuming one of the MAX_REMOTE_SLOTS pool.

    Verified with raw-socket tests (same token reconnecting mid-game
    reclaims its exact slot; a different token gets a new one; an
    unclaimed slot times out and is removed - confirmed via a temporarily
    shortened timeout, reverted after) and with a real browser via
    Puppeteer (token persists across an actual page reload, and the
    server logs the reclaim).

    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---
 TODO.md               |   4 -
 src/doom/i_webinput.c | 225 +++++++++++++++++++++++++++++++++++++++++++++-----
 2 files changed, 206 insertions(+), 23 deletions(-)

diff --git a/TODO.md b/TODO.md
index a45677ec..3f40d473 100644
--- a/TODO.md
+++ b/TODO.md
@@ -27,10 +27,6 @@ and unstructured wish list of features and improvements. The bug tracker
   - Strife v1.1 emulation (for demo IWAD support)
   - Screensaver mode

-* Infinite splitscreen fork:
-  - Losing connection or reloading the phone controller page spawns a new
-    player instead of reclaiming the existing one.
-
 Crazy pie in the sky ideas:

 * Automatic WAD installer - download and run TCs from a list automatically
diff --git a/src/doom/i_webinput.c b/src/doom/i_webinput.c
index 7c25f3f9..91cb834a 100644
--- a/src/doom/i_webinput.c
+++ b/src/doom/i_webinput.c
@@ -44,14 +44,17 @@
 #include "doomtype.h"
 #include "g_game.h"
 #include "doomdef.h"
+#include "doomstat.h"
 #include "sha1.h"
 #include "m_argv.h"
+#include "m_misc.h"
 #include "v_video.h"
 #include "qrcodegen.h"
 #include "w_wad.h"
 #include "z_zone.h"
 #include "deh_str.h"
 #include "i_joystick.h"
+#include "i_timer.h"

 // One tracked TCP connection per player slot. Normally only
 // MAXPLAYERS-2 slots are ever up for grabs by a phone (0 is the host,
@@ -62,6 +65,7 @@
 #define MAX_REMOTE_SLOTS MAXPLAYERS
 #define HTTP_HEADER_MAX  4096
 #define WS_PAYLOAD_MAX   256
+#define TOKEN_LEN        40

 typedef struct
 {
@@ -71,12 +75,35 @@ typedef struct
     int     player_slot;    // -1 until bound by I_WebInputTic
     unsigned int input_bits;
     int     turn_axis;      // -127..127, see G_SetRemoteInputState
+    char    token[TOKEN_LEN]; // from the join page's localStorage-backed
+                               // per-device id (see join_page's "?token="
+                               // query string) - empty if the request had
+                               // none. Used to reclaim a still-in-game
+                               // player after a reconnect - see
+                               // slot_owner_token/slot_disconnect_time.
 } remote_client_t;

 static remote_client_t remote_clients[MAX_REMOTE_SLOTS];
 static SDL_mutex *remote_lock = NULL;
 static boolean webinput_enabled = false;

+// Which device token (if any) most recently claimed each player slot,
+// and (if disconnected) when - kept separate from remote_clients[]
+// because it must survive the TCP connection dropping, not just live
+// alongside it. Indexed by player slot, not by remote_clients[] index -
+// see I_WebInputTic's join/leave handling and ReclaimTimedOutSlots.
+static char slot_owner_token[MAXPLAYERS][TOKEN_LEN];
+static boolean slot_owner_disconnected[MAXPLAYERS];
+static int slot_disconnect_time[MAXPLAYERS];
+
+// How long a disconnected phone's player stays in the game, in tics,
+// before being removed like any other departure - long enough to
+// survive a page reload or a brief WiFi hiccup (the join page also
+// auto-reconnects on its own - see join_page's ws.onclose) without the
+// level yanking their body out from under them, short enough that a
+// phone that's genuinely gone doesn't leave a permanent idle corpse.
+#define RECLAIM_TIMEOUT_TICS (30 * TICRATE)
+
 // -qrstart: show a join QR code in place of any empty splitscreen grid
 // cell (see I_WebInputDrawJoinQR, called from R_RenderSplitViews).
 static boolean qrstart_enabled = false;
@@ -143,11 +170,26 @@ static const char join_page[] =
 "</div>"
 "<script>"
 "var bits=0,turnAxis=0,sentBits=-1,sentTurn=-999;"
-"var ws=new WebSocket('ws://'+location.host+'/');"
 "var st=document.getElementById('status');"
-"ws.onopen=function(){st.textContent='connected';};"
-"ws.onclose=function(){st.textContent='disconnected';};"
+"var token=localStorage.getItem('doomToken');"
+"if(!token){"
+"token=Date.now().toString(36)+Math.random().toString(36).slice(2);"
+"localStorage.setItem('doomToken',token);"
+"}"
+"var ws,reconnectTimer=null;"
+"function connect(){"
+"ws=new WebSocket('ws://'+location.host+'/?token='+token);"
 "ws.binaryType='arraybuffer';"
+"ws.onopen=function(){"
+"st.textContent='connected';"
+"sentBits=-1;sentTurn=-999;"
+"};"
+"ws.onclose=function(){"
+"st.textContent='reconnecting...';"
+"if(!reconnectTimer)reconnectTimer=setTimeout(function(){reconnectTimer=null;connect();},1000);"
+"};"
+"}"
+"connect();"
 "function send(){"
 "if((bits!==sentBits || turnAxis!==sentTurn) && ws.readyState===1){"
 "ws.send(new Uint8Array([bits, turnAxis & 0xff]));"
@@ -270,6 +312,38 @@ static const char *FindHeaderCI(const char *haystack, const char *needle)
     return NULL;
 }

+// Extracts the "token" query-string value from a request line like
+// "GET /?token=abc123 HTTP/1.1\r\n..." into out (empty string if the
+// request had none). The join page's token is always plain alphanumeric
+// (see its Date.now()/Math.random().toString(36) generator in
+// join_page), so no URL-decoding is needed.
+static void ExtractToken(const char *request, char *out, size_t outsize)
+{
+    const char *p = FindHeaderCI(request, "token=");
+    const char *line_end;
+    size_t i;
+
+    out[0] = '\0';
+
+    if (p == NULL)
+        return;
+
+    // Only within the request line itself (up to its \r), not into
+    // headers below it.
+    line_end = strchr(request, '\r');
+    if (line_end == NULL || p >= line_end)
+        return;
+
+    p += strlen("token=");
+
+    for (i = 0; i < outsize - 1 && p[i] != '\0' && p[i] != '&'
+             && p[i] != ' ' && p[i] != '\r' && p[i] != '\n'; i++)
+    {
+        out[i] = p[i];
+    }
+    out[i] = '\0';
+}
+
 static boolean RecvExact(int fd, void *buf, int len)
 {
     int got = 0;
@@ -464,18 +538,30 @@ static int ClientThreadFunc(void *data)
     if (reqlen < 0)
     {
         close(fd);
+        SDL_LockMutex(remote_lock);
+        remote_clients[idx].active = false;
+        SDL_UnlockMutex(remote_lock);
         return 0;
     }

     if (FindHeaderCI(request, "Upgrade: websocket") == NULL
      || !DoHandshake(fd, request))
     {
+        // A plain page load (or anything else that isn't a WebSocket
+        // upgrade) never becomes a tracked player connection - free
+        // this remote_clients[] slot back up immediately instead of
+        // leaving it permanently marked active (see ListenerThreadFunc,
+        // which only ever hands out slots where !active).
         ServeJoinPage(fd);
         close(fd);
+        SDL_LockMutex(remote_lock);
+        remote_clients[idx].active = false;
+        SDL_UnlockMutex(remote_lock);
         return 0;
     }

     SDL_LockMutex(remote_lock);
+    ExtractToken(request, remote_clients[idx].token, sizeof(remote_clients[idx].token));
     remote_clients[idx].pending_join = true;
     SDL_UnlockMutex(remote_lock);

@@ -819,6 +905,14 @@ void I_WebInputInit(int port)
         remote_clients[i].player_slot = -1;
         remote_clients[i].input_bits = 0;
         remote_clients[i].turn_axis = 0;
+        remote_clients[i].token[0] = '\0';
+    }
+
+    for (i = 0; i < MAXPLAYERS; i++)
+    {
+        slot_owner_token[i][0] = '\0';
+        slot_owner_disconnected[i] = false;
+        slot_disconnect_time[i] = 0;
     }

     // Turning for a phone-driven slot goes through joyxmove[] (see
@@ -846,6 +940,39 @@ void I_WebInputInit(int port)
     webinput_enabled = true;
 }

+// Removes any player whose owning phone disconnected more than
+// RECLAIM_TIMEOUT_TICS ago without reconnecting (see the do_leave/
+// do_join handling in I_WebInputTic) - called every tic, outside
+// remote_lock since G_RemoveLocalPlayer runs game logic, not
+// networking state.
+static void ReclaimTimedOutSlots(void)
+{
+    int slot;
+
+    for (slot = 0; slot < MAXPLAYERS; slot++)
+    {
+        if (!slot_owner_disconnected[slot])
+            continue;
+
+        if (!playeringame[slot])
+        {
+            // Already gone by some other path (e.g. the debug leave
+            // key, or a fresh game) - nothing left to reclaim.
+            slot_owner_disconnected[slot] = false;
+            slot_owner_token[slot][0] = '\0';
+            continue;
+        }
+
+        if (I_GetTime() - slot_disconnect_time[slot] < RECLAIM_TIMEOUT_TICS)
+            continue;
+
+        G_RemoveLocalPlayer(slot);
+        slot_owner_disconnected[slot] = false;
+        slot_owner_token[slot][0] = '\0';
+        printf("[webinput] player slot %d timed out waiting to reconnect, removing\n", slot);
+    }
+}
+
 void I_WebInputTic(void)
 {
     int i;
@@ -868,10 +995,20 @@ void I_WebInputTic(void)

         if (do_leave)
         {
-            SDL_UnlockMutex(remote_lock);
+            // Don't remove the player from the game on a mere dropped
+            // connection - a page reload or brief WiFi hiccup shouldn't
+            // yank someone's body out of the level. Just free up this
+            // connection slot and start the reclaim clock; the same
+            // device reconnecting with the same token (see do_join
+            // below) picks the player back up exactly where they were.
+            // ReclaimTimedOutSlots (called below, outside the lock)
+            // handles actually removing them if nobody reclaims in
+            // time.
             if (rc->player_slot >= 0)
-                G_RemoveLocalPlayer(rc->player_slot);
-            SDL_LockMutex(remote_lock);
+            {
+                slot_owner_disconnected[rc->player_slot] = true;
+                slot_disconnect_time[rc->player_slot] = I_GetTime();
+            }
             rc->player_slot = -1;
             rc->active = false;
             continue;
@@ -880,23 +1017,71 @@ void I_WebInputTic(void)
         if (do_join && rc->player_slot < 0)
         {
             int slot;
-            // Slots 0/1 are normally reserved for a local host/WASD
-            // zone (see MAX_REMOTE_SLOTS); under -nolocal there is no
-            // local player to reserve them for, so the first phone can
-            // take slot 0 itself.
-            int start_slot = nolocal_enabled ? 0 : 2;
-            SDL_UnlockMutex(remote_lock);
-            for (slot = start_slot; slot < MAXPLAYERS; slot++)
+            int reclaim_slot = -1;
+
+            // Same device (matching token) reconnecting to a player
+            // that's still in the game (nobody else has since claimed
+            // that connection slot, and the timeout sweep hasn't
+            // removed them) - resume controlling them in place, no
+            // despawn/respawn.
+            if (rc->token[0] != '\0')
             {
-                if (G_AddLocalPlayer(slot))
-                    break;
+                for (slot = 0; slot < MAXPLAYERS; slot++)
+                {
+                    int j;
+                    boolean claimed_elsewhere = false;
+
+                    if (!playeringame[slot] || !slot_owner_disconnected[slot])
+                        continue;
+                    if (strncmp(slot_owner_token[slot], rc->token, TOKEN_LEN) != 0)
+                        continue;
+
+                    for (j = 0; j < MAX_REMOTE_SLOTS; j++)
+                    {
+                        if (j != i && remote_clients[j].active
+                         && remote_clients[j].player_slot == slot)
+                        {
+                            claimed_elsewhere = true;
+                            break;
+                        }
+                    }
+
+                    if (!claimed_elsewhere)
+                    {
+                        reclaim_slot = slot;
+                        break;
+                    }
+                }
             }
-            SDL_LockMutex(remote_lock);

-            if (slot < MAXPLAYERS)
+            if (reclaim_slot >= 0)
             {
-                rc->player_slot = slot;
-                printf("[webinput] phone bound to player slot %d\n", slot);
+                rc->player_slot = reclaim_slot;
+                slot_owner_disconnected[reclaim_slot] = false;
+                printf("[webinput] phone reclaimed player slot %d\n", reclaim_slot);
+            }
+            else
+            {
+                // Slots 0/1 are normally reserved for a local host/WASD
+                // zone (see MAX_REMOTE_SLOTS); under -nolocal there is
+                // no local player to reserve them for, so the first
+                // phone can take slot 0 itself.
+                int start_slot = nolocal_enabled ? 0 : 2;
+                SDL_UnlockMutex(remote_lock);
+                for (slot = start_slot; slot < MAXPLAYERS; slot++)
+                {
+                    if (G_AddLocalPlayer(slot))
+                        break;
+                }
+                SDL_LockMutex(remote_lock);
+
+                if (slot < MAXPLAYERS)
+                {
+                    rc->player_slot = slot;
+                    M_StringCopy(slot_owner_token[slot], rc->token, TOKEN_LEN);
+                    slot_owner_disconnected[slot] = false;
+                    printf("[webinput] phone bound to player slot %d\n", slot);
+                }
             }
         }

@@ -909,6 +1094,8 @@ void I_WebInputTic(void)
     }

     SDL_UnlockMutex(remote_lock);
+
+    ReclaimTimedOutSlots();
 }

 #else // _WIN32