Add room management, relay capacity, and rejection handling

- Remove hardcoded multiplayer config from emscripten config.cpp
- Add relay HTTP endpoints for room preview (GET) and creation (POST)
  with capacity check, CORS headers, and configurable max players
- Add WebSocket rejection detection (room full/503) via onclose flag
- Add CheckRejected extension call in IsleApp::Tick for clean shutdown
  through SDL_APP_SUCCESS path instead of calling exit()
- Set Module._exitCode in JS for sessionStorage-based toast after reload
This commit is contained in:
Christian Semmler 2026-03-01 14:37:08 -08:00
parent a7ba34cada
commit 5b56db3c33
No known key found for this signature in database
GPG Key ID: 086DAA1360BEEE5C
12 changed files with 129 additions and 9 deletions

View File

@ -17,12 +17,6 @@ void Emscripten_SetupDefaultConfigOverrides(dictionary* p_dictionary)
iniparser_set(p_dictionary, "isle:Full Screen", "false"); iniparser_set(p_dictionary, "isle:Full Screen", "false");
iniparser_set(p_dictionary, "isle:Flip Surfaces", "true"); iniparser_set(p_dictionary, "isle:Flip Surfaces", "true");
iniparser_set(p_dictionary, "extensions", NULL);
iniparser_set(p_dictionary, "extensions:multiplayer", "true");
iniparser_set(p_dictionary, "multiplayer", NULL);
iniparser_set(p_dictionary, "multiplayer:relay url", "ws://localhost:8787");
iniparser_set(p_dictionary, "multiplayer:room", "default");
// Emscripten-only for now // Emscripten-only for now
Emscripten_SetScaleAspect(iniparser_getboolean(p_dictionary, "isle:Original Aspect Ratio", true)); Emscripten_SetScaleAspect(iniparser_getboolean(p_dictionary, "isle:Original Aspect Ratio", true));
Emscripten_SetOriginalResolution(iniparser_getboolean(p_dictionary, "isle:Original Resolution", true)); Emscripten_SetOriginalResolution(iniparser_getboolean(p_dictionary, "isle:Original Resolution", true));

View File

@ -38,6 +38,7 @@
#include <array> #include <array>
#include <extensions/extensions.h> #include <extensions/extensions.h>
#include <extensions/multiplayer.h>
#include <miniwin/miniwindevice.h> #include <miniwin/miniwindevice.h>
#include <type_traits> #include <type_traits>
#include <vec.h> #include <vec.h>
@ -91,6 +92,8 @@
#include <psp2/kernel/clib.h> #include <psp2/kernel/clib.h>
#endif #endif
using namespace Extensions;
DECOMP_SIZE_ASSERT(IsleApp, 0x8c) DECOMP_SIZE_ASSERT(IsleApp, 0x8c)
// GLOBAL: ISLE 0x410030 // GLOBAL: ISLE 0x410030
@ -1293,6 +1296,12 @@ inline bool IsleApp::Tick()
if (!Lego()) { if (!Lego()) {
return true; return true;
} }
if (Extension<MultiplayerExt>::Call(CheckRejected).value_or(FALSE)) {
g_closed = TRUE;
return true;
}
if (!TickleManager()) { if (!TickleManager()) {
return true; return true;
} }

View File

@ -33,6 +33,9 @@ class MultiplayerExt {
static std::string relayUrl; static std::string relayUrl;
static std::string room; static std::string room;
// Returns true if the multiplayer connection was rejected (e.g. room full).
static MxBool CheckRejected();
static void SetNetworkManager(Multiplayer::NetworkManager* p_networkManager); static void SetNetworkManager(Multiplayer::NetworkManager* p_networkManager);
static Multiplayer::NetworkManager* GetNetworkManager(); static Multiplayer::NetworkManager* GetNetworkManager();
@ -44,9 +47,11 @@ class MultiplayerExt {
#ifdef EXTENSIONS #ifdef EXTENSIONS
constexpr auto HandleWorldEnable = &MultiplayerExt::HandleWorldEnable; constexpr auto HandleWorldEnable = &MultiplayerExt::HandleWorldEnable;
constexpr auto HandleEntityNotify = &MultiplayerExt::HandleEntityNotify; constexpr auto HandleEntityNotify = &MultiplayerExt::HandleEntityNotify;
constexpr auto CheckRejected = &MultiplayerExt::CheckRejected;
#else #else
constexpr decltype(&MultiplayerExt::HandleWorldEnable) HandleWorldEnable = nullptr; constexpr decltype(&MultiplayerExt::HandleWorldEnable) HandleWorldEnable = nullptr;
constexpr decltype(&MultiplayerExt::HandleEntityNotify) HandleEntityNotify = nullptr; constexpr decltype(&MultiplayerExt::HandleEntityNotify) HandleEntityNotify = nullptr;
constexpr decltype(&MultiplayerExt::CheckRejected) CheckRejected = nullptr;
#endif #endif
}; // namespace Extensions }; // namespace Extensions

View File

@ -39,6 +39,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 WasRejected() const;
void OnWorldEnabled(LegoWorld* p_world); void OnWorldEnabled(LegoWorld* p_world);
void OnWorldDisabled(LegoWorld* p_world); void OnWorldDisabled(LegoWorld* p_world);
@ -84,6 +85,7 @@ class NetworkManager : public MxCore {
static const uint32_t BROADCAST_INTERVAL_MS = 66; // ~15Hz static const uint32_t BROADCAST_INTERVAL_MS = 66; // ~15Hz
static const uint32_t TIMEOUT_MS = 5000; // 5 second timeout static const uint32_t TIMEOUT_MS = 5000; // 5 second timeout
static const int EXIT_ROOM_FULL = 10;
}; };
} // namespace Multiplayer } // namespace Multiplayer

View File

@ -14,6 +14,7 @@ class NetworkTransport {
virtual void Connect(const char* p_roomId) = 0; virtual void Connect(const char* p_roomId) = 0;
virtual void Disconnect() = 0; virtual void Disconnect() = 0;
virtual bool IsConnected() const = 0; virtual bool IsConnected() const = 0;
virtual bool WasRejected() const = 0;
// Send binary data to all peers via relay // Send binary data to all peers via relay
virtual void Send(const uint8_t* p_data, size_t p_length) = 0; virtual void Send(const uint8_t* p_data, size_t p_length) = 0;

View File

@ -17,6 +17,7 @@ class WebSocketTransport : public NetworkTransport {
void Connect(const char* p_roomId) override; void Connect(const char* p_roomId) override;
void Disconnect() override; void Disconnect() override;
bool IsConnected() const override; bool IsConnected() const override;
bool WasRejected() const override;
void Send(const uint8_t* p_data, size_t p_length) override; void Send(const uint8_t* p_data, size_t p_length) override;
size_t Receive(std::function<void(const uint8_t*, size_t)> p_callback) override; size_t Receive(std::function<void(const uint8_t*, size_t)> p_callback) override;
@ -24,6 +25,7 @@ class WebSocketTransport : public NetworkTransport {
std::string m_relayBaseUrl; std::string m_relayBaseUrl;
int m_socketId; int m_socketId;
volatile int32_t m_connectedFlag; // Shared with JS main thread via Atomics volatile int32_t m_connectedFlag; // Shared with JS main thread via Atomics
volatile int32_t m_rejectedFlag; // Set by JS when connection is rejected (e.g. room full)
uint8_t m_recvBuf[8192]; uint8_t m_recvBuf[8192];
}; };

View File

@ -100,6 +100,15 @@ MxBool MultiplayerExt::HandleEntityNotify(LegoEntity* p_entity)
return s_networkManager->HandleEntityMutation(p_entity, changeType); return s_networkManager->HandleEntityMutation(p_entity, changeType);
} }
MxBool MultiplayerExt::CheckRejected()
{
if (s_networkManager && s_networkManager->WasRejected()) {
return TRUE;
}
return FALSE;
}
void MultiplayerExt::SetNetworkManager(Multiplayer::NetworkManager* p_networkManager) void MultiplayerExt::SetNetworkManager(Multiplayer::NetworkManager* p_networkManager)
{ {
s_networkManager = p_networkManager; s_networkManager = p_networkManager;

View File

@ -116,6 +116,11 @@ bool NetworkManager::IsConnected() const
return m_transport && m_transport->IsConnected(); return m_transport && m_transport->IsConnected();
} }
bool NetworkManager::WasRejected() const
{
return m_transport && m_transport->WasRejected();
}
void NetworkManager::OnWorldEnabled(LegoWorld* p_world) void NetworkManager::OnWorldEnabled(LegoWorld* p_world)
{ {
if (!p_world) { if (!p_world) {

View File

@ -12,10 +12,18 @@ import {
} from "./protocol"; } from "./protocol";
import type { Env } from "./relay"; import type { Env } from "./relay";
const CORS_HEADERS: Record<string, string> = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Cross-Origin-Resource-Policy": "cross-origin",
};
export class GameRoom implements DurableObject { export class GameRoom implements DurableObject {
private connections = new Map<number, WebSocket>(); private connections = new Map<number, WebSocket>();
private nextPeerId = 1; private nextPeerId = 1;
private hostPeerId = 0; private hostPeerId = 0;
private maxPlayers = 5;
constructor( constructor(
private state: DurableObjectState, private state: DurableObjectState,
@ -23,8 +31,17 @@ export class GameRoom implements DurableObject {
) {} ) {}
async fetch(request: Request): Promise<Response> { async fetch(request: Request): Promise<Response> {
// Handle non-WebSocket requests (HTTP API)
if (request.headers.get("Upgrade") !== "websocket") { if (request.headers.get("Upgrade") !== "websocket") {
return new Response("Expected WebSocket", { status: 426 }); return this.handleHttpRequest(request);
}
// Capacity check
if (this.connections.size >= this.maxPlayers) {
return new Response("Room is full", {
status: 503,
headers: CORS_HEADERS,
});
} }
const pair = new WebSocketPair(); const pair = new WebSocketPair();
@ -49,6 +66,64 @@ export class GameRoom implements DurableObject {
return new Response(null, { status: 101, webSocket: client }); return new Response(null, { status: 101, webSocket: client });
} }
// ---- HTTP API ----
private async handleHttpRequest(request: Request): Promise<Response> {
const method = request.method.toUpperCase();
if (method === "OPTIONS") {
return new Response(null, { status: 204, headers: CORS_HEADERS });
}
if (method === "POST") {
try {
const body = (await request.json()) as {
maxPlayers?: number;
};
const ceiling = this.env.MAX_PLAYERS_CEILING
? Number(this.env.MAX_PLAYERS_CEILING)
: 64;
if (body.maxPlayers !== undefined) {
this.maxPlayers = Math.max(
2,
Math.min(body.maxPlayers, ceiling)
);
}
} catch {
// Ignore parse errors, keep defaults
}
return new Response(
JSON.stringify({ maxPlayers: this.maxPlayers }),
{
headers: {
"Content-Type": "application/json",
...CORS_HEADERS,
},
}
);
}
if (method === "GET") {
return new Response(
JSON.stringify({
players: this.connections.size,
maxPlayers: this.maxPlayers,
}),
{
headers: {
"Content-Type": "application/json",
...CORS_HEADERS,
},
}
);
}
return new Response("Method Not Allowed", {
status: 405,
headers: CORS_HEADERS,
});
}
// ---- Connection lifecycle ---- // ---- Connection lifecycle ----
private assignHostIfNeeded(peerId: number, ws: WebSocket): void { private assignHostIfNeeded(peerId: number, ws: WebSocket): void {

View File

@ -2,6 +2,7 @@ export { GameRoom } from "./gameroom";
export interface Env { export interface Env {
GAME_ROOM: DurableObjectNamespace; GAME_ROOM: DurableObjectNamespace;
MAX_PLAYERS_CEILING?: number;
} }
export default { export default {

View File

@ -7,6 +7,9 @@ bindings = [
{ name = "GAME_ROOM", class_name = "GameRoom" } { name = "GAME_ROOM", class_name = "GameRoom" }
] ]
[vars]
MAX_PLAYERS_CEILING = 64
[[migrations]] [[migrations]]
tag = "v1" tag = "v1"
new_sqlite_classes = ["GameRoom"] new_sqlite_classes = ["GameRoom"]

View File

@ -9,7 +9,7 @@ namespace Multiplayer
{ {
WebSocketTransport::WebSocketTransport(const std::string& p_relayBaseUrl) WebSocketTransport::WebSocketTransport(const std::string& p_relayBaseUrl)
: m_relayBaseUrl(p_relayBaseUrl), m_socketId(-1), m_connectedFlag(0) : m_relayBaseUrl(p_relayBaseUrl), m_socketId(-1), m_connectedFlag(0), m_rejectedFlag(0)
{ {
// clang-format off // clang-format off
MAIN_THREAD_EM_ASM({ MAIN_THREAD_EM_ASM({
@ -35,10 +35,13 @@ void WebSocketTransport::Connect(const char* p_roomId)
std::string url = m_relayBaseUrl + "/room/" + p_roomId; std::string url = m_relayBaseUrl + "/room/" + p_roomId;
m_rejectedFlag = 0;
// clang-format off // clang-format off
m_socketId = MAIN_THREAD_EM_ASM_INT({ m_socketId = MAIN_THREAD_EM_ASM_INT({
var url = UTF8ToString($0); var url = UTF8ToString($0);
var connPtr = $1; var connPtr = $1;
var rejPtr = $2;
var socketId = Module._mpNextSocketId++; var socketId = Module._mpNextSocketId++;
Module._mpMessageQueues[socketId] = []; Module._mpMessageQueues[socketId] = [];
@ -58,7 +61,13 @@ void WebSocketTransport::Connect(const char* p_roomId)
}; };
ws.onclose = function() { ws.onclose = function() {
var wasConnected = Atomics.load(HEAP32, connPtr >> 2);
Atomics.store(HEAP32, connPtr >> 2, 0); Atomics.store(HEAP32, connPtr >> 2, 0);
if (!wasConnected) {
// Never connected — server rejected (room full / 503)
Atomics.store(HEAP32, rejPtr >> 2, 1);
Module._exitCode = 10;
}
}; };
ws.onerror = function() { ws.onerror = function() {
@ -72,7 +81,7 @@ void WebSocketTransport::Connect(const char* p_roomId)
} }
return socketId; return socketId;
}, url.c_str(), &m_connectedFlag); }, url.c_str(), &m_connectedFlag, &m_rejectedFlag);
// clang-format on // clang-format on
if (m_socketId <= 0) { if (m_socketId <= 0) {
@ -104,6 +113,11 @@ bool WebSocketTransport::IsConnected() const
return m_socketId > 0 && m_connectedFlag != 0; return m_socketId > 0 && m_connectedFlag != 0;
} }
bool WebSocketTransport::WasRejected() const
{
return m_rejectedFlag != 0;
}
void WebSocketTransport::Send(const uint8_t* p_data, size_t p_length) void WebSocketTransport::Send(const uint8_t* p_data, size_t p_length)
{ {
if (m_socketId <= 0 || !m_connectedFlag) { if (m_socketId <= 0 || !m_connectedFlag) {