Add WebSocket reconnection with exponential backoff

Automatically reconnect when the WebSocket connection is lost (e.g.
phone sleep, alt-tab, network blip) instead of exiting the game.

- Add reconnection state machine (CONNECTED → RECONNECTING → CONNECTED
  or DISCONNECTED) with exponential backoff (1s→30s cap, 10 max attempts)
- Add OnConnectionStatusChanged callback (connected/reconnecting/failed/
  rejected) to PlatformCallbacks, with Emscripten CustomEvent dispatch
  and native SDL_Log implementations
- Add WorldStateSync::ResetForReconnect() to clear session state
- Only exit the game on room-full rejection (WasRejected); connection
  loss is handled internally by the state machine
- Rename WasDisconnected→WasRejected, CheckDisconnected→CheckRejected,
  IsMultiplayerDisconnected→IsMultiplayerRejected through the full call
  chain for accurate naming
- Remove Module._exitCode from JS onclose; use C++ callback +
  sessionStorage for room-full signaling instead
- Clean up EXIT_CONNECTION_LOST constant (obsolete)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Semmler 2026-03-16 13:34:45 -07:00
parent 176aef1d90
commit 982957ee5e
No known key found for this signature in database
GPG Key ID: 086DAA1360BEEE5C
14 changed files with 180 additions and 23 deletions

View File

@ -1300,7 +1300,7 @@ inline bool IsleApp::Tick()
} }
#ifdef EXTENSIONS #ifdef EXTENSIONS
if (Extensions::IsMultiplayerDisconnected()) { if (Extensions::IsMultiplayerRejected()) {
g_closed = TRUE; g_closed = TRUE;
return true; return true;
} }

View File

@ -45,7 +45,7 @@ class MultiplayerExt {
static MxBool IsClonedCharacter(const char* p_name); static MxBool IsClonedCharacter(const char* p_name);
static void HandleBeforeSaveLoad(); static void HandleBeforeSaveLoad();
static void HandleSaveLoaded(); static void HandleSaveLoaded();
static MxBool CheckDisconnected(); static MxBool CheckRejected();
static Multiplayer::NetworkManager* GetNetworkManager(); static Multiplayer::NetworkManager* GetNetworkManager();
@ -58,7 +58,7 @@ class MultiplayerExt {
}; };
#ifdef EXTENSIONS #ifdef EXTENSIONS
LEGO1_EXPORT bool IsMultiplayerDisconnected(); LEGO1_EXPORT bool IsMultiplayerRejected();
#endif #endif
namespace MP namespace MP
@ -72,7 +72,7 @@ constexpr auto HandleROIClick = &MultiplayerExt::HandleROIClick;
constexpr auto IsClonedCharacter = &MultiplayerExt::IsClonedCharacter; constexpr auto IsClonedCharacter = &MultiplayerExt::IsClonedCharacter;
constexpr auto HandleBeforeSaveLoad = &MultiplayerExt::HandleBeforeSaveLoad; constexpr auto HandleBeforeSaveLoad = &MultiplayerExt::HandleBeforeSaveLoad;
constexpr auto HandleSaveLoaded = &MultiplayerExt::HandleSaveLoaded; constexpr auto HandleSaveLoaded = &MultiplayerExt::HandleSaveLoaded;
constexpr auto CheckDisconnected = &MultiplayerExt::CheckDisconnected; constexpr auto CheckRejected = &MultiplayerExt::CheckRejected;
#else #else
constexpr decltype(&MultiplayerExt::HandleCreate) HandleCreate = nullptr; constexpr decltype(&MultiplayerExt::HandleCreate) HandleCreate = nullptr;
constexpr decltype(&MultiplayerExt::HandleWorldEnable) HandleWorldEnable = nullptr; constexpr decltype(&MultiplayerExt::HandleWorldEnable) HandleWorldEnable = nullptr;
@ -82,7 +82,7 @@ constexpr decltype(&MultiplayerExt::HandleROIClick) HandleROIClick = nullptr;
constexpr decltype(&MultiplayerExt::IsClonedCharacter) IsClonedCharacter = nullptr; constexpr decltype(&MultiplayerExt::IsClonedCharacter) IsClonedCharacter = nullptr;
constexpr decltype(&MultiplayerExt::HandleBeforeSaveLoad) HandleBeforeSaveLoad = nullptr; constexpr decltype(&MultiplayerExt::HandleBeforeSaveLoad) HandleBeforeSaveLoad = nullptr;
constexpr decltype(&MultiplayerExt::HandleSaveLoaded) HandleSaveLoaded = nullptr; constexpr decltype(&MultiplayerExt::HandleSaveLoaded) HandleSaveLoaded = nullptr;
constexpr decltype(&MultiplayerExt::CheckDisconnected) CheckDisconnected = nullptr; constexpr decltype(&MultiplayerExt::CheckRejected) CheckRejected = nullptr;
#endif #endif
} // namespace MP } // namespace MP

View File

@ -30,6 +30,12 @@ class NameBubbleRenderer;
class NetworkManager : public MxCore { class NetworkManager : public MxCore {
public: public:
enum ConnectionState {
STATE_DISCONNECTED,
STATE_CONNECTED,
STATE_RECONNECTING
};
NetworkManager(); NetworkManager();
~NetworkManager() override; ~NetworkManager() override;
@ -49,7 +55,7 @@ class NetworkManager : public MxCore {
void Connect(const char* p_roomId); void Connect(const char* p_roomId);
void Disconnect(); void Disconnect();
bool IsConnected() const; bool IsConnected() const;
bool WasDisconnected() const; bool WasRejected() const;
void SetWalkAnimation(uint8_t p_walkAnimId); void SetWalkAnimation(uint8_t p_walkAnimId);
void SetIdleAnimation(uint8_t p_idleAnimId); void SetIdleAnimation(uint8_t p_idleAnimId);
@ -114,6 +120,10 @@ class NetworkManager : public MxCore {
void RemoveRemotePlayer(uint32_t p_peerId); void RemoveRemotePlayer(uint32_t p_peerId);
void RemoveAllRemotePlayers(); void RemoveAllRemotePlayers();
void CheckConnectionState();
void AttemptReconnect();
void ResetStateAfterReconnect();
void NotifyPlayerCountChanged(); void NotifyPlayerCountChanged();
void EnforceDisableNPCs(); void EnforceDisableNPCs();
@ -149,8 +159,18 @@ class NetworkManager : public MxCore {
bool m_lastCameraEnabled; bool m_lastCameraEnabled;
bool m_wasInRestrictedArea; bool m_wasInRestrictedArea;
static const uint32_t BROADCAST_INTERVAL_MS = 66; // ~15Hz ConnectionState m_connectionState;
static const uint32_t TIMEOUT_MS = 5000; // 5 second timeout bool m_wasRejected;
std::string m_roomId;
uint32_t m_reconnectAttempt;
uint32_t m_reconnectDelay;
uint32_t m_nextReconnectTime;
static const uint32_t BROADCAST_INTERVAL_MS = 66; // ~15Hz
static const uint32_t TIMEOUT_MS = 5000; // 5 second timeout
static const uint32_t RECONNECT_INITIAL_DELAY_MS = 1000;
static const uint32_t RECONNECT_MAX_DELAY_MS = 30000;
static const uint32_t RECONNECT_MAX_ATTEMPTS = 10;
}; };
} // namespace Multiplayer } // namespace Multiplayer

View File

@ -7,9 +7,6 @@
namespace Multiplayer namespace Multiplayer
{ {
static const int EXIT_ROOM_FULL = 10;
static const int EXIT_CONNECTION_LOST = 11;
class NetworkTransport { class NetworkTransport {
public: public:
virtual ~NetworkTransport() = default; virtual ~NetworkTransport() = default;

View File

@ -3,6 +3,11 @@
namespace Multiplayer namespace Multiplayer
{ {
static const int CONNECTION_STATUS_CONNECTED = 0;
static const int CONNECTION_STATUS_RECONNECTING = 1;
static const int CONNECTION_STATUS_FAILED = 2;
static const int CONNECTION_STATUS_REJECTED = 3;
class PlatformCallbacks { class PlatformCallbacks {
public: public:
virtual ~PlatformCallbacks() = default; virtual ~PlatformCallbacks() = default;
@ -19,6 +24,9 @@ class PlatformCallbacks {
// Called when the allow-customization setting changes. // Called when the allow-customization setting changes.
virtual void OnAllowCustomizeChanged(bool p_enabled) = 0; virtual void OnAllowCustomizeChanged(bool p_enabled) = 0;
// Called when the connection status changes (connected, reconnecting, failed).
virtual void OnConnectionStatusChanged(int p_status) = 0;
}; };
} // namespace Multiplayer } // namespace Multiplayer

View File

@ -13,6 +13,7 @@ class EmscriptenCallbacks : public PlatformCallbacks {
void OnThirdPersonChanged(bool p_enabled) override; void OnThirdPersonChanged(bool p_enabled) override;
void OnNameBubblesChanged(bool p_enabled) override; void OnNameBubblesChanged(bool p_enabled) override;
void OnAllowCustomizeChanged(bool p_enabled) override; void OnAllowCustomizeChanged(bool p_enabled) override;
void OnConnectionStatusChanged(int p_status) override;
}; };
} // namespace Multiplayer } // namespace Multiplayer

View File

@ -13,6 +13,7 @@ class NativeCallbacks : public PlatformCallbacks {
void OnThirdPersonChanged(bool p_enabled) override; void OnThirdPersonChanged(bool p_enabled) override;
void OnNameBubblesChanged(bool p_enabled) override; void OnNameBubblesChanged(bool p_enabled) override;
void OnAllowCustomizeChanged(bool p_enabled) override; void OnAllowCustomizeChanged(bool p_enabled) override;
void OnConnectionStatusChanged(int p_status) override;
}; };
} // namespace Multiplayer } // namespace Multiplayer

View File

@ -48,6 +48,9 @@ class WorldStateSync {
// Returns TRUE if the local action should be suppressed (non-host). // Returns TRUE if the local action should be suppressed (non-host).
MxBool HandleSkyLightMutation(uint8_t p_entityType, uint8_t p_changeType); MxBool HandleSkyLightMutation(uint8_t p_entityType, uint8_t p_changeType);
// Resets session state for reconnection (peer ID, sequence, host, pending events).
void ResetForReconnect();
private: private:
void ApplySkyLightState(const char* p_skyColor, int p_lightPos); void ApplySkyLightState(const char* p_skyColor, int p_lightPos);
void SendSnapshotRequest(); void SendSnapshotRequest();

View File

@ -251,9 +251,9 @@ MxBool MultiplayerExt::IsClonedCharacter(const char* p_name)
return s_networkManager->IsClonedCharacter(p_name) ? TRUE : FALSE; return s_networkManager->IsClonedCharacter(p_name) ? TRUE : FALSE;
} }
MxBool MultiplayerExt::CheckDisconnected() MxBool MultiplayerExt::CheckRejected()
{ {
if (s_networkManager && s_networkManager->WasDisconnected()) { if (s_networkManager && s_networkManager->WasRejected()) {
return TRUE; return TRUE;
} }
@ -265,7 +265,7 @@ Multiplayer::NetworkManager* MultiplayerExt::GetNetworkManager()
return s_networkManager; return s_networkManager;
} }
bool Extensions::IsMultiplayerDisconnected() bool Extensions::IsMultiplayerRejected()
{ {
return Extension<MultiplayerExt>::Call(MP::CheckDisconnected).value_or(FALSE); return Extension<MultiplayerExt>::Call(MP::CheckRejected).value_or(FALSE);
} }

View File

@ -49,7 +49,9 @@ NetworkManager::NetworkManager()
m_sequence(0), m_lastBroadcastTime(0), m_lastValidActorId(0), m_localAllowRemoteCustomize(true), m_sequence(0), m_lastBroadcastTime(0), m_lastValidActorId(0), m_localAllowRemoteCustomize(true),
m_inIsleWorld(false), m_registered(false), m_pendingToggleThirdPerson(false), m_pendingToggleNameBubbles(false), m_inIsleWorld(false), m_registered(false), m_pendingToggleThirdPerson(false), m_pendingToggleNameBubbles(false),
m_pendingWalkAnim(-1), m_pendingIdleAnim(-1), m_pendingEmote(-1), m_pendingToggleAllowCustomize(false), m_pendingWalkAnim(-1), m_pendingIdleAnim(-1), m_pendingEmote(-1), m_pendingToggleAllowCustomize(false),
m_disableAllNPCs(false), m_showNameBubbles(true), m_lastCameraEnabled(false), m_wasInRestrictedArea(false) m_disableAllNPCs(false), m_showNameBubbles(true), m_lastCameraEnabled(false), m_wasInRestrictedArea(false),
m_connectionState(STATE_DISCONNECTED), m_wasRejected(false), m_reconnectAttempt(0), m_reconnectDelay(0),
m_nextReconnectTime(0)
{ {
} }
@ -66,6 +68,7 @@ static ThirdPersonCamera::Controller* GetCamera()
MxResult NetworkManager::Tickle() MxResult NetworkManager::Tickle()
{ {
ProcessPendingRequests(); ProcessPendingRequests();
CheckConnectionState();
if (m_disableAllNPCs) { if (m_disableAllNPCs) {
EnforceDisableNPCs(); EnforceDisableNPCs();
@ -171,12 +174,15 @@ void NetworkManager::Shutdown()
void NetworkManager::Connect(const char* p_roomId) void NetworkManager::Connect(const char* p_roomId)
{ {
if (m_transport) { if (m_transport) {
m_roomId = p_roomId;
m_transport->Connect(p_roomId); m_transport->Connect(p_roomId);
m_connectionState = STATE_CONNECTED;
} }
} }
void NetworkManager::Disconnect() void NetworkManager::Disconnect()
{ {
m_connectionState = STATE_DISCONNECTED;
if (m_transport) { if (m_transport) {
m_transport->Disconnect(); m_transport->Disconnect();
} }
@ -188,9 +194,9 @@ bool NetworkManager::IsConnected() const
return m_transport && m_transport->IsConnected(); return m_transport && m_transport->IsConnected();
} }
bool NetworkManager::WasDisconnected() const bool NetworkManager::WasRejected() const
{ {
return m_transport && m_transport->WasDisconnected(); return m_wasRejected;
} }
void NetworkManager::OnWorldEnabled(LegoWorld* p_world) void NetworkManager::OnWorldEnabled(LegoWorld* p_world)
@ -322,6 +328,88 @@ void NetworkManager::EnforceDisableNPCs()
} }
} }
void NetworkManager::CheckConnectionState()
{
if (!m_transport || m_connectionState == STATE_DISCONNECTED) {
return;
}
if (m_connectionState == STATE_CONNECTED) {
if (!m_transport->WasDisconnected()) {
return;
}
if (m_transport->WasRejected()) {
// Room full on initial connect - flag for game loop to exit
m_wasRejected = true;
m_connectionState = STATE_DISCONNECTED;
if (m_callbacks) {
m_callbacks->OnConnectionStatusChanged(CONNECTION_STATUS_REJECTED);
}
return;
}
// Connection lost - enter reconnection
m_connectionState = STATE_RECONNECTING;
RemoveAllRemotePlayers();
m_reconnectAttempt = 0;
m_reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
m_nextReconnectTime = SDL_GetTicks() + m_reconnectDelay;
if (m_callbacks) {
m_callbacks->OnConnectionStatusChanged(CONNECTION_STATUS_RECONNECTING);
}
return;
}
// STATE_RECONNECTING
if (m_transport->IsConnected()) {
ResetStateAfterReconnect();
m_connectionState = STATE_CONNECTED;
if (m_callbacks) {
m_callbacks->OnConnectionStatusChanged(CONNECTION_STATUS_CONNECTED);
}
return;
}
uint32_t now = SDL_GetTicks();
if (now < m_nextReconnectTime) {
return;
}
if (m_reconnectAttempt >= RECONNECT_MAX_ATTEMPTS) {
// Give up - stay alive but without multiplayer
m_connectionState = STATE_DISCONNECTED;
if (m_callbacks) {
m_callbacks->OnConnectionStatusChanged(CONNECTION_STATUS_FAILED);
}
return;
}
AttemptReconnect();
}
void NetworkManager::AttemptReconnect()
{
m_reconnectAttempt++;
m_transport->Disconnect();
m_transport->Connect(m_roomId.c_str());
m_reconnectDelay = SDL_min(m_reconnectDelay * 2, RECONNECT_MAX_DELAY_MS);
m_nextReconnectTime = SDL_GetTicks() + m_reconnectDelay;
}
void NetworkManager::ResetStateAfterReconnect()
{
m_localPeerId = 0;
m_hostPeerId = 0;
m_sequence = 0;
m_lastBroadcastTime = 0;
m_worldSync.ResetForReconnect();
}
void NetworkManager::ProcessPendingRequests() void NetworkManager::ProcessPendingRequests()
{ {
ThirdPersonCamera::Controller* cam = GetCamera(); ThirdPersonCamera::Controller* cam = GetCamera();

View File

@ -50,6 +50,20 @@ void EmscriptenCallbacks::OnAllowCustomizeChanged(bool p_enabled)
DispatchBoolEvent("allowCustomizeChanged", p_enabled); DispatchBoolEvent("allowCustomizeChanged", p_enabled);
} }
void EmscriptenCallbacks::OnConnectionStatusChanged(int p_status)
{
// clang-format off
MAIN_THREAD_EM_ASM({
var canvas = Module.canvas;
if (canvas) {
canvas.dispatchEvent(new CustomEvent('connectionStatusChanged', {
detail: { status: $0 }
}));
}
}, p_status);
// clang-format on
}
} // namespace Multiplayer } // namespace Multiplayer
#endif // __EMSCRIPTEN__ #endif // __EMSCRIPTEN__

View File

@ -44,8 +44,6 @@ void WebSocketTransport::Connect(const char* p_roomId)
var connPtr = $1; var connPtr = $1;
var discPtr = $2; var discPtr = $2;
var everConnPtr = $3; var everConnPtr = $3;
var exitRoomFull = $4;
var exitConnLost = $5;
var socketId = Module._mpNextSocketId++; var socketId = Module._mpNextSocketId++;
Module._mpMessageQueues[socketId] = []; Module._mpMessageQueues[socketId] = [];
@ -66,10 +64,8 @@ void WebSocketTransport::Connect(const char* p_roomId)
}; };
ws.onclose = function() { ws.onclose = function() {
var wasEverConnected = Atomics.load(HEAP32, everConnPtr >> 2);
Atomics.store(HEAP32, connPtr >> 2, 0); Atomics.store(HEAP32, connPtr >> 2, 0);
Atomics.store(HEAP32, discPtr >> 2, 1); Atomics.store(HEAP32, discPtr >> 2, 1);
Module._exitCode = wasEverConnected ? exitConnLost : exitRoomFull;
}; };
ws.onerror = function() { ws.onerror = function() {
@ -83,7 +79,7 @@ void WebSocketTransport::Connect(const char* p_roomId)
} }
return socketId; return socketId;
}, url.c_str(), &m_connectedFlag, &m_disconnectedFlag, &m_wasEverConnected, EXIT_ROOM_FULL, EXIT_CONNECTION_LOST); }, url.c_str(), &m_connectedFlag, &m_disconnectedFlag, &m_wasEverConnected);
// clang-format on // clang-format on
if (m_socketId <= 0) { if (m_socketId <= 0) {

View File

@ -32,6 +32,26 @@ void NativeCallbacks::OnAllowCustomizeChanged(bool p_enabled)
SDL_Log("[Multiplayer] Allow customization: %s", p_enabled ? "enabled" : "disabled"); SDL_Log("[Multiplayer] Allow customization: %s", p_enabled ? "enabled" : "disabled");
} }
void NativeCallbacks::OnConnectionStatusChanged(int p_status)
{
const char* statusStr = "unknown";
switch (p_status) {
case CONNECTION_STATUS_CONNECTED:
statusStr = "connected";
break;
case CONNECTION_STATUS_RECONNECTING:
statusStr = "reconnecting";
break;
case CONNECTION_STATUS_FAILED:
statusStr = "failed";
break;
case CONNECTION_STATUS_REJECTED:
statusStr = "rejected (room full)";
break;
}
SDL_Log("[Multiplayer] Connection status: %s", statusStr);
}
} // namespace Multiplayer } // namespace Multiplayer
#endif // !__EMSCRIPTEN__ #endif // !__EMSCRIPTEN__

View File

@ -66,6 +66,15 @@ void WorldStateSync::ApplySkyLightState(const char* p_skyColor, int p_lightPos)
VariableTable()->SetVariable("lightposition", buf); VariableTable()->SetVariable("lightposition", buf);
} }
void WorldStateSync::ResetForReconnect()
{
m_localPeerId = 0;
m_sequence = 0;
m_isHost = false;
m_snapshotRequested = false;
m_pendingWorldEvents.clear();
}
void WorldStateSync::OnHostChanged() void WorldStateSync::OnHostChanged()
{ {
if (!m_isHost) { if (!m_isHost) {