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