Skip to content

Conversation

@ryanbarlow97
Copy link
Contributor

@ryanbarlow97 ryanbarlow97 commented Dec 28, 2025

Description:

Changes game lobbies into websockets instead of polling

Please complete the following:

  • I have added screenshots for all UI updates
  • I process any text displayed to the user through translateText() and I've added it to the en.json file
  • I have added relevant tests to the test directory
  • I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced

Please put your Discord username so you can be contacted if a bug or regression is found:

w.o.n

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 28, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

Replaces periodic HTTP polling with a WebSocket push path for public lobbies. Adds PublicLobbySocket client with reconnection and HTTP fallback, server-side WebSocket broadcasting in Master, client lobby update handling and map-image loading, and exposes PublicLobby.stop() to terminate updates and reset UI.

Changes

Cohort / File(s) Summary
WebSocket client
src/client/LobbySocket.ts
New PublicLobbySocket, types LobbyUpdateHandler and LobbySocketOptions, start()/stop(), WS lifecycle, reconnection scheduling, WS message parsing, and HTTP polling fallback after configurable WS attempts.
Public lobby UI
src/client/PublicLobby.ts
Replaces interval-based polling with PublicLobbySocket; adds handleLobbiesUpdate to set this.lobbies, compute start times, load map images; removes legacy fetch/interval state and exposes public stop().
Server broadcast
src/server/Master.ts
Adds WebSocketServer handling, connectedClients tracking, publicLobbiesData, broadcastLobbies() on lobby refresh; /api/public_lobbies now returns publicLobbiesData and broadcasts updates on refresh.
Tests
tests/client/LobbySocket.test.ts
Adds unit tests for WS-delivered lobbies_update messages and HTTP polling fallback when WS attempts are exhausted.
Dev config
vite.config.ts
WebSocket proxy route changed from /socket to /lobbies.
Manifests & packages
manifest_file, package.json
Minor manifest/package updates referenced by diff.

Sequence Diagram(s)

sequenceDiagram
    participant UI as PublicLobby (UI)
    participant Socket as PublicLobbySocket
    participant Server as Master (WS)
    participant HTTP as /api/public_lobbies

    UI->>Socket: start()
    Socket->>Server: WS CONNECT /lobbies
    note right of Server `#D6EAF8`: add client to connectedClients\nsend current lobbies immediately
    Server-->>Socket: {"type":"lobbies_update","lobbies":[...]}
    Socket-->>UI: onLobbiesUpdate(lobbies)
    UI->>UI: handleLobbiesUpdate()\n(update state, compute times, load maps)

    rect rgb(230,255,230)
      note over Server: On lobby data change
      Server-->>Socket: broadcast lobbies_update
      Socket-->>UI: onLobbiesUpdate(lobbies)
      UI->>UI: handleLobbiesUpdate()
    end

    rect rgb(255,245,230)
      note over Socket: If WS closes repeatedly\nand max attempts reached
      Socket->>HTTP: GET /api/public_lobbies (fallback poll)
      HTTP-->>Socket: JSON lobbies
      Socket-->>UI: onLobbiesUpdate(lobbies)
    end

    UI->>Socket: stop()
    Socket->>Server: WS CLOSE
    Socket->>Socket: clear timers & intervals
    UI->>UI: reset UI state (currLobby, highlights)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested labels

Feature - Backend, Feature - Frontend

Suggested reviewers

  • scottanderson
  • evanpelle
  • jrouillard

Poem

Sockets hum where polls once beat,
Lobbies push updates, neat.
Maps load quick, timers fall,
Reconnect waits, then serves them all.
Stop the line; the UI’s complete. 🪄

Pre-merge checks

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: replacing periodic lobby polling with WebSocket-based updates across multiple files.
Description check ✅ Passed The description directly addresses the changeset by stating it replaces game lobby polling with WebSocket-based updates and includes confirmation of testing and documentation.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
src/server/Master.ts (1)

68-82: Avoid double JSON parse/stringify overhead.

publicLobbiesJsonStr is already a JSON string. Parsing it just to stringify it again in the message wastes CPU cycles. You can build the message directly using string concatenation or store the lobbies object separately.

Proposed fix
 function broadcastLobbies() {
-  const message = JSON.stringify({
-    type: "lobbies_update",
-    data: JSON.parse(publicLobbiesJsonStr || '{"lobbies":[]}'),
-  });
+  const lobbiesData = publicLobbiesJsonStr || '{"lobbies":[]}';
+  const message = `{"type":"lobbies_update","data":${lobbiesData}}`;

   connectedClients.forEach((client) => {
     if (client.readyState === WebSocket.OPEN) {
       client.send(message);
     } else {
       connectedClients.delete(client);
     }
   });
 }
src/client/PublicLobby.ts (1)

126-132: Fetch lobbies immediately when starting fallback polling.

When WebSocket fails and you fall back to HTTP, there's a 1-second delay before the first fetch. For a better user experience, fetch immediately on fallback start.

Proposed fix
   private startFallbackPolling() {
     if (this.fallbackPollInterval !== null) return;
     console.log("Starting HTTP fallback polling");
+    this.fetchLobbiesHTTP(); // Fetch immediately
     this.fallbackPollInterval = window.setInterval(() => {
       this.fetchLobbiesHTTP();
     }, 1000);
   }
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7284ded and 019ebd9.

📒 Files selected for processing (2)
  • src/client/PublicLobby.ts
  • src/server/Master.ts
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: woodydrn
Repo: openfrontio/OpenFrontIO PR: 1836
File: src/client/Main.ts:482-482
Timestamp: 2025-08-17T20:48:49.411Z
Learning: In PR #1836, user woodydrn prefers to keep changes minimal and focused on the specific issue (clientID persistence) rather than refactoring redundant code in JoinLobbyEvent dispatchers. They want to avoid scope creep in focused bug fix PRs.
📚 Learning: 2025-06-09T02:20:43.637Z
Learnt from: VariableVince
Repo: openfrontio/OpenFrontIO PR: 1110
File: src/client/Main.ts:293-295
Timestamp: 2025-06-09T02:20:43.637Z
Learning: In src/client/Main.ts, during game start in the handleJoinLobby callback, UI elements are hidden using direct DOM manipulation with classList.add("hidden") for consistency. This includes modals, buttons, and error divs. The codebase follows this pattern rather than using component APIs for hiding elements during game transitions.

Applied to files:

  • src/client/PublicLobby.ts
🧬 Code graph analysis (1)
src/client/PublicLobby.ts (3)
src/core/Schemas.ts (2)
  • GameInfo (130-136)
  • GameID (25-25)
src/core/game/GameView.ts (1)
  • gameID (910-912)
src/client/TerrainMapFileLoader.ts (1)
  • terrainMapFileLoader (4-4)
🪛 GitHub Actions: 🧪 CI
src/client/PublicLobby.ts

[error] 1-1: Prettier formatting check failed. Code style issues found in src/client/PublicLobby.ts. Run 'prettier --write' to fix.

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Deploy to openfront.dev
🔇 Additional comments (5)
src/server/Master.ts (2)

95-119: WebSocket setup is clean. Consider adding a heartbeat mechanism for production.

The connection, close, and error handlers work well. Two things to note:

  1. Lines 103-108 duplicate the JSON parse/stringify pattern from broadcastLobbies. Extract this into a shared helper or use the string concatenation fix from above.

  2. For production, WebSocket connections can silently fail without triggering close events. A ping/pong heartbeat helps detect stale connections. This is optional for a draft PR but worth adding before release.


273-281: Good optimization: broadcast only when lobbies change.

Comparing JSON strings to detect changes and broadcasting only on update reduces unnecessary network traffic. This is a solid approach.

src/client/PublicLobby.ts (3)

96-104: Same double-counting issue in the catch block.

When the constructor throws, you increment attempts and then potentially call scheduleReconnect, which will create a new connection that may also fail. The close event won't fire here since no connection was established. This path is fine, but the logic should be consistent with the event handlers above.

Consider whether you want the catch block to also trigger fallback if attempts are exhausted:

     } catch (error) {
       console.error("Error connecting WebSocket:", error);
       this.wsConnectionAttempts++;
       if (this.wsConnectionAttempts >= this.maxWsAttempts) {
         this.startFallbackPolling();
       } else {
         this.scheduleReconnect();
       }
     }

This part looks correct. Just verify the interaction with event-based retry logic once you remove the increment from the error handler.


152-165: Clean centralized handler for lobby updates.

Good refactor. The handleLobbiesUpdate method handles both WebSocket and HTTP sources uniformly. The logic for tracking lobby start times and loading map images is straightforward.


282-288: Good cleanup in stop() method.

Properly disconnects WebSocket, stops fallback polling, and resets UI state. This avoids resource leaks when the component is stopped.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/client/PublicLobby.ts (1)

81-97: The double-counting issue may have been reintroduced.

The error handler (line 96) still increments wsConnectionAttempts, causing each failed connection to count twice (error fires before close in browsers). This was previously flagged and marked as addressed in commit 423f4d4, but the increment remains in the current code.

Proposed fix: Remove increment from error handler
       this.ws.addEventListener("error", (error) => {
         console.error("WebSocket error:", error);
-        this.wsConnectionAttempts++;
+        // Attempt counting happens in close handler
       });
🧹 Nitpick comments (4)
src/client/PublicLobby.ts (4)

109-118: Consider explicitly removing WebSocket event listeners.

While setting this.ws = null typically allows garbage collection, explicitly removing the event listeners ensures cleanup, especially since the handlers reference this (component instance). Store handler references or use AbortController for cleaner removal.

Proposed pattern using AbortController
+  private wsAbortController: AbortController | null = null;

   private connectWebSocket() {
     try {
       const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
       const wsUrl = `${protocol}//${window.location.host}/socket`;

       this.ws = new WebSocket(wsUrl);
+      this.wsAbortController = new AbortController();
+      const signal = this.wsAbortController.signal;

-      this.ws.addEventListener("open", () => {
+      this.ws.addEventListener("open", () => {
         // ... existing code
-      });
+      }, { signal });

-      this.ws.addEventListener("message", (event) => {
+      this.ws.addEventListener("message", (event) => {
         // ... existing code
-      });
+      }, { signal });

-      this.ws.addEventListener("close", () => {
+      this.ws.addEventListener("close", () => {
         // ... existing code
-      });
+      }, { signal });

-      this.ws.addEventListener("error", (error) => {
+      this.ws.addEventListener("error", (error) => {
         // ... existing code
-      });
+      }, { signal });
     } catch (error) {
       // ... existing catch block
     }
   }

   private disconnectWebSocket() {
+    if (this.wsAbortController) {
+      this.wsAbortController.abort();
+      this.wsAbortController = null;
+    }
     if (this.ws) {
       this.ws.close();
       this.ws = null;
     }
     if (this.wsReconnectTimeout !== null) {
       clearTimeout(this.wsReconnectTimeout);
       this.wsReconnectTimeout = null;
     }
   }

128-134: Extract hardcoded polling interval to a configurable property.

The fallback polling interval (1000ms) is hardcoded, while other timing values (wsReconnectDelay, debounceDelay) are class properties. For consistency and easier tuning, extract this to a property.

Proposed refactor
   private wsReconnectDelay: number = 3000;
+  private fallbackPollDelay: number = 1000;
   private fallbackPollInterval: number | null = null;

   // ...

   private startFallbackPolling() {
     if (this.fallbackPollInterval !== null) return;
     console.log("Starting HTTP fallback polling");
     this.fallbackPollInterval = window.setInterval(() => {
       this.fetchLobbiesHTTP();
-    }, 1000);
+    }, this.fallbackPollDelay);
   }

51-107: Consider adding a connection timeout for the WebSocket.

If the server doesn't respond, the WebSocket connection will hang until the browser's default timeout. Adding an explicit timeout provides better control over failure detection and faster fallback to HTTP polling.

Example timeout pattern
private wsConnectionTimeout: number | null = null;
private wsConnectionTimeoutDelay: number = 5000; // 5 seconds

private connectWebSocket() {
  try {
    const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
    const wsUrl = `${protocol}//${window.location.host}/socket`;

    this.ws = new WebSocket(wsUrl);
    
    // Set connection timeout
    this.wsConnectionTimeout = window.setTimeout(() => {
      if (this.ws && this.ws.readyState !== WebSocket.OPEN) {
        console.warn("WebSocket connection timeout");
        this.ws.close();
        // The close handler will handle reconnect/fallback logic
      }
    }, this.wsConnectionTimeoutDelay);

    this.ws.addEventListener("open", () => {
      console.log("WebSocket connected to lobby updates");
      // Clear connection timeout on successful open
      if (this.wsConnectionTimeout !== null) {
        clearTimeout(this.wsConnectionTimeout);
        this.wsConnectionTimeout = null;
      }
      this.wsConnectionAttempts = 0;
      // ... rest of open handler
    });
    
    // ... rest of event handlers
  } catch (error) {
    // ... existing catch block
  }
}

private disconnectWebSocket() {
  if (this.wsConnectionTimeout !== null) {
    clearTimeout(this.wsConnectionTimeout);
    this.wsConnectionTimeout = null;
  }
  // ... rest of disconnectWebSocket
}

109-118: Optionally check WebSocket readyState before calling close().

Calling close() on an already-closed WebSocket is safe (no-op), but checking readyState is defensive and can provide clearer logging.

Optional defensive check
   private disconnectWebSocket() {
     if (this.ws) {
-      this.ws.close();
+      if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
+        this.ws.close();
+      }
       this.ws = null;
     }
     if (this.wsReconnectTimeout !== null) {
       clearTimeout(this.wsReconnectTimeout);
       this.wsReconnectTimeout = null;
     }
   }
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 019ebd9 and 423f4d4.

📒 Files selected for processing (1)
  • src/client/PublicLobby.ts
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
Learnt from: woodydrn
Repo: openfrontio/OpenFrontIO PR: 1836
File: src/client/Main.ts:482-482
Timestamp: 2025-08-17T20:48:49.411Z
Learning: In PR #1836, user woodydrn prefers to keep changes minimal and focused on the specific issue (clientID persistence) rather than refactoring redundant code in JoinLobbyEvent dispatchers. They want to avoid scope creep in focused bug fix PRs.
📚 Learning: 2025-06-09T02:20:43.637Z
Learnt from: VariableVince
Repo: openfrontio/OpenFrontIO PR: 1110
File: src/client/Main.ts:293-295
Timestamp: 2025-06-09T02:20:43.637Z
Learning: In src/client/Main.ts, during game start in the handleJoinLobby callback, UI elements are hidden using direct DOM manipulation with classList.add("hidden") for consistency. This includes modals, buttons, and error divs. The codebase follows this pattern rather than using component APIs for hiding elements during game transitions.

Applied to files:

  • src/client/PublicLobby.ts
📚 Learning: 2025-08-17T20:48:49.411Z
Learnt from: woodydrn
Repo: openfrontio/OpenFrontIO PR: 1836
File: src/client/Main.ts:482-482
Timestamp: 2025-08-17T20:48:49.411Z
Learning: In PR #1836, user woodydrn prefers to keep changes minimal and focused on the specific issue (clientID persistence) rather than refactoring redundant code in JoinLobbyEvent dispatchers. They want to avoid scope creep in focused bug fix PRs.

Applied to files:

  • src/client/PublicLobby.ts
🧬 Code graph analysis (1)
src/client/PublicLobby.ts (3)
src/core/Schemas.ts (2)
  • GameInfo (130-136)
  • GameID (25-25)
src/core/game/GameView.ts (1)
  • gameID (910-912)
src/client/TerrainMapFileLoader.ts (1)
  • terrainMapFileLoader (4-4)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Deploy to openfront.dev

@ryanbarlow97 ryanbarlow97 added this to the Backlog milestone Dec 28, 2025
@ryanbarlow97 ryanbarlow97 added the Performance Performance optimization label Dec 28, 2025
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
src/client/PublicLobby.ts (1)

160-173: Memory leak: stale lobby entries not cleaned up.

When lobbies are removed from updates, their entries persist in lobbyIDToStart and mapImages. Over long sessions, stale data accumulates. The mapImages entries may hold blob URLs that won't be garbage-collected without explicit revocation.

Proposed fix: Prune stale entries
  private handleLobbiesUpdate(lobbies: GameInfo[]) {
+   // Build set of current lobby IDs
+   const currentIDs = new Set(lobbies.map(l => l.gameID));
+
+   // Remove stale entries from lobbyIDToStart
+   for (const gameID of this.lobbyIDToStart.keys()) {
+     if (!currentIDs.has(gameID)) {
+       this.lobbyIDToStart.delete(gameID);
+     }
+   }
+
+   // Remove stale entries from mapImages and revoke blob URLs
+   for (const gameID of this.mapImages.keys()) {
+     if (!currentIDs.has(gameID)) {
+       const imageUrl = this.mapImages.get(gameID);
+       if (imageUrl && imageUrl.startsWith('blob:')) {
+         URL.revokeObjectURL(imageUrl);
+       }
+       this.mapImages.delete(gameID);
+     }
+   }
+
    this.lobbies = lobbies;
    this.lobbies.forEach((l) => {
      if (!this.lobbyIDToStart.has(l.gameID)) {
        const msUntilStart = l.msUntilStart ?? 0;
        this.lobbyIDToStart.set(l.gameID, msUntilStart + Date.now());
      }

      if (l.gameConfig && !this.mapImages.has(l.gameID)) {
        this.loadMapImage(l.gameID, l.gameConfig.gameMap);
      }
    });
    this.requestUpdate();
  }
🧹 Nitpick comments (1)
src/client/PublicLobby.ts (1)

52-112: Double-counting fix looks good; minor edge case with flag.

The wsAttemptCounted flag properly prevents double counting between error and close events for a single connection attempt, addressing the prior review concern.

However, there's a subtle edge case: the flag is reset to false each time connectWebSocket is called (line 58), but old WebSocket instances retain their event listeners. If a previous WebSocket's close event fires after you've already started a new connection attempt, it could increment wsConnectionAttempts unexpectedly. In practice, this is unlikely because the close event should complete before the reconnect delay (3 seconds) expires.

Consider storing a connection ID or generation counter if you observe reconnection issues in production.

Optional: More robust approach using per-connection flag
private connectWebSocket() {
  try {
    const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
    const wsUrl = `${protocol}//${window.location.host}/socket`;
    
    this.ws = new WebSocket(wsUrl);
    let attemptCounted = false; // Per-connection flag instead of instance variable
    
    this.ws.addEventListener("close", () => {
      console.log("WebSocket disconnected, attempting to reconnect...");
      if (!attemptCounted) {
        attemptCounted = true;
        this.wsConnectionAttempts++;
      }
      // ... rest of close handler
    });
    
    // ... rest of setup
  } catch (error) {
    // ... error handling
  }
}
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 423f4d4 and c4a4ca3.

📒 Files selected for processing (1)
  • src/client/PublicLobby.ts
🧰 Additional context used
🧠 Learnings (4)
📓 Common learnings
Learnt from: woodydrn
Repo: openfrontio/OpenFrontIO PR: 1836
File: src/client/Main.ts:482-482
Timestamp: 2025-08-17T20:48:49.411Z
Learning: In PR #1836, user woodydrn prefers to keep changes minimal and focused on the specific issue (clientID persistence) rather than refactoring redundant code in JoinLobbyEvent dispatchers. They want to avoid scope creep in focused bug fix PRs.
📚 Learning: 2025-06-09T02:20:43.637Z
Learnt from: VariableVince
Repo: openfrontio/OpenFrontIO PR: 1110
File: src/client/Main.ts:293-295
Timestamp: 2025-06-09T02:20:43.637Z
Learning: In src/client/Main.ts, during game start in the handleJoinLobby callback, UI elements are hidden using direct DOM manipulation with classList.add("hidden") for consistency. This includes modals, buttons, and error divs. The codebase follows this pattern rather than using component APIs for hiding elements during game transitions.

Applied to files:

  • src/client/PublicLobby.ts
📚 Learning: 2025-08-17T20:48:49.411Z
Learnt from: woodydrn
Repo: openfrontio/OpenFrontIO PR: 1836
File: src/client/Main.ts:482-482
Timestamp: 2025-08-17T20:48:49.411Z
Learning: In PR #1836, user woodydrn prefers to keep changes minimal and focused on the specific issue (clientID persistence) rather than refactoring redundant code in JoinLobbyEvent dispatchers. They want to avoid scope creep in focused bug fix PRs.

Applied to files:

  • src/client/PublicLobby.ts
📚 Learning: 2025-10-21T20:06:04.823Z
Learnt from: Saphereye
Repo: openfrontio/OpenFrontIO PR: 2233
File: src/client/HostLobbyModal.ts:891-891
Timestamp: 2025-10-21T20:06:04.823Z
Learning: For the HumansVsNations game mode in `src/client/HostLobbyModal.ts` and related files, the implementation strategy is to generate all nations and adjust their strength for balancing, rather than limiting lobby size based on the number of available nations on the map.

Applied to files:

  • src/client/PublicLobby.ts
🧬 Code graph analysis (1)
src/client/PublicLobby.ts (3)
src/core/Schemas.ts (2)
  • GameInfo (130-136)
  • GameID (25-25)
src/core/game/GameView.ts (1)
  • gameID (910-912)
src/client/TerrainMapFileLoader.ts (1)
  • terrainMapFileLoader (4-4)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Deploy to openfront.dev
🔇 Additional comments (4)
src/client/PublicLobby.ts (4)

114-131: LGTM!

Reconnect scheduling properly guards against multiple pending timeouts, and cleanup is thorough.


133-158: LGTM!

HTTP fallback is straightforward with proper guards and error handling.


175-184: LGTM!

Async map loading is clean with proper error handling.


290-296: LGTM!

Public stop() method provides comprehensive cleanup of WebSocket, polling, state, and animations.

coderabbitai[bot]
coderabbitai bot previously approved these changes Dec 28, 2025
@ryanbarlow97 ryanbarlow97 changed the title lobby websocket [DRAFT!!] lobby websocket instead of polling Dec 28, 2025
@ryanbarlow97 ryanbarlow97 marked this pull request as ready for review December 28, 2025 20:24
@ryanbarlow97 ryanbarlow97 requested a review from a team as a code owner December 28, 2025 20:24
Copilot AI review requested due to automatic review settings December 28, 2025 20:24
@ryanbarlow97 ryanbarlow97 modified the milestones: Backlog, v29 Dec 28, 2025
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR replaces HTTP polling with WebSocket connections for real-time game lobby updates. The implementation adds a WebSocket server on the master process and updates the client to use WebSocket connections with automatic fallback to HTTP polling if WebSocket connections fail after 3 attempts.

Key Changes:

  • WebSocket server setup on /socket endpoint with automatic client broadcast on lobby changes
  • Client WebSocket connection with reconnection logic and HTTP polling fallback mechanism
  • Removal of interval-based polling in favor of push-based updates

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 12 comments.

File Description
src/server/Master.ts Adds WebSocket server setup, client connection management, and broadcasts lobby updates to connected clients when lobbies change
src/client/PublicLobby.ts Replaces HTTP polling with WebSocket connection, implements reconnection logic with fallback to HTTP polling after max attempts, and consolidates lobby update handling

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Co-authored-by: Copilot <[email protected]>
coderabbitai[bot]
coderabbitai bot previously approved these changes Dec 29, 2025
store the json as an object

always broadcast

rename the path to /lobbies
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/client/LobbySocket.ts (1)

3-9: Export types for better developer experience.

The LobbyUpdateHandler and LobbySocketOptions types are part of the public API but aren't exported. Consumers need these when creating instances or typing their callbacks.

🔎 Proposed fix
-type LobbyUpdateHandler = (lobbies: GameInfo[]) => void;
+export type LobbyUpdateHandler = (lobbies: GameInfo[]) => void;

-interface LobbySocketOptions {
+export interface LobbySocketOptions {
  reconnectDelay?: number;
  maxWsAttempts?: number;
  pollIntervalMs?: number;
}
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7a700e6 and 3e16b29.

📒 Files selected for processing (4)
  • src/client/LobbySocket.ts
  • src/server/Master.ts
  • tests/client/LobbySocket.test.ts
  • vite.config.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/server/Master.ts
  • tests/client/LobbySocket.test.ts
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: woodydrn
Repo: openfrontio/OpenFrontIO PR: 1836
File: src/client/Main.ts:482-482
Timestamp: 2025-08-17T20:48:49.411Z
Learning: In PR #1836, user woodydrn prefers to keep changes minimal and focused on the specific issue (clientID persistence) rather than refactoring redundant code in JoinLobbyEvent dispatchers. They want to avoid scope creep in focused bug fix PRs.
📚 Learning: 2025-06-09T02:20:43.637Z
Learnt from: VariableVince
Repo: openfrontio/OpenFrontIO PR: 1110
File: src/client/Main.ts:293-295
Timestamp: 2025-06-09T02:20:43.637Z
Learning: In src/client/Main.ts, during game start in the handleJoinLobby callback, UI elements are hidden using direct DOM manipulation with classList.add("hidden") for consistency. This includes modals, buttons, and error divs. The codebase follows this pattern rather than using component APIs for hiding elements during game transitions.

Applied to files:

  • src/client/LobbySocket.ts
🧬 Code graph analysis (1)
src/client/LobbySocket.ts (1)
src/core/Schemas.ts (1)
  • GameInfo (130-136)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Deploy to openfront.dev
🔇 Additional comments (7)
vite.config.ts (1)

103-107: LGTM! Proxy path aligns with WebSocket endpoint.

The updated path /lobbies matches the WebSocket URL constructed in LobbySocket.ts line 46, ensuring proper routing for the new WebSocket-based lobby updates.

src/client/LobbySocket.ts (6)

23-31: Clean constructor with sensible defaults.

The default configuration values are reasonable for production use: 3-second reconnect delay, 3 WebSocket attempts before HTTP fallback, and 1-second polling interval.


70-89: Good error handling with safe fallback.

The message parsing uses optional chaining and provides an empty array fallback, preventing crashes from malformed messages. The decision to close the socket on parse errors is reasonable to avoid processing corrupt data streams.


91-105: Reconnection logic correctly prevents double-counting.

The wsAttemptCounted flag ensures connection attempts are only incremented once per connection try, even if both error and close events fire.


124-130: Good guard against concurrent reconnection attempts.

The null check on line 125 prevents multiple reconnect timers from running simultaneously, avoiding connection storms.


143-150: LGTM! Fallback polling with immediate first fetch.

The immediate call to fetchLobbiesHTTP() on line 146 ensures the UI updates quickly when falling back from WebSocket, before starting the interval.


159-170: Add runtime validation before casting to GameInfo[] at line 166.

Line 166 uses an unsafe type assertion: data.lobbies as GameInfo[]. If the API returns unexpected data structure, this will cause runtime errors without any type checking. Validate the response structure before the cast—for example, check that data.lobbies is an array and contains objects with the expected properties.

The WebSocket path at line 74 also lacks validation but avoids the type assertion by using optional chaining (?.). Both paths should validate the data before passing it to onLobbiesUpdate().

⛔ Skipped due to learnings
Learnt from: scamiv
Repo: openfrontio/OpenFrontIO PR: 2607
File: src/core/execution/PlayerExecution.ts:271-295
Timestamp: 2025-12-13T14:58:29.645Z
Learning: In src/core/execution/PlayerExecution.ts surroundedBySamePlayer(), the `as Player` cast on `mg.playerBySmallID(scan.enemyId)` is intentional. Since scan.enemyId comes from ownerID() on an owned tile and playerBySmallID() only returns Player or undefined, the cast expresses a known invariant. The maintainers prefer loud failures (runtime errors) over silent masking (early returns with guards) for corrupted game state scenarios at trusted call sites.

coderabbitai[bot]
coderabbitai bot previously approved these changes Dec 30, 2025
// Broadcast lobbies to all connected clients
function broadcastLobbies() {
const dataStr = JSON.stringify(publicLobbiesData);
const message = `{"type":"lobbies_update","data":${dataStr}}`;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead, do:

const message = JSON.stringify({type: "lobbies_update", data: publicLobbiesData}))

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated

Comment on lines 106 to 107
const lobbiesJson = JSON.stringify(publicLobbiesData);
ws.send(`{"type":"lobbies_update","data":${lobbiesJson}}`);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (2)
src/server/Master.ts (2)

63-88: Good implementation of broadcast with proper cleanup.

The deferred deletion pattern avoids modifying the Set during iteration, and the message is stringified once before sending to all clients.

One optional improvement: the inline type { lobbies: GameInfo[] } could be extracted to a named type (e.g., PublicLobbiesData) for reuse and clarity, especially if the client also defines a matching type.


101-130: Consider adding an error handler on the WebSocketServer.

The connection-level error handling looks good. However, the wss instance itself can emit errors (though rare when attached to an existing server). Adding a handler prevents unhandled exceptions from crashing the master process.

🔎 Suggested addition after line 102
  const wss = new WebSocketServer({ server, path: "/lobbies" });

+  wss.on("error", (error) => {
+    log.error("WebSocketServer error:", error);
+  });
+
  wss.on("connection", (ws: WebSocket) => {
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fc66f8b and 10a92cb.

📒 Files selected for processing (1)
  • src/server/Master.ts
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: woodydrn
Repo: openfrontio/OpenFrontIO PR: 1836
File: src/client/Main.ts:482-482
Timestamp: 2025-08-17T20:48:49.411Z
Learning: In PR #1836, user woodydrn prefers to keep changes minimal and focused on the specific issue (clientID persistence) rather than refactoring redundant code in JoinLobbyEvent dispatchers. They want to avoid scope creep in focused bug fix PRs.
📚 Learning: 2025-06-09T02:20:43.637Z
Learnt from: VariableVince
Repo: openfrontio/OpenFrontIO PR: 1110
File: src/client/Main.ts:293-295
Timestamp: 2025-06-09T02:20:43.637Z
Learning: In src/client/Main.ts, during game start in the handleJoinLobby callback, UI elements are hidden using direct DOM manipulation with classList.add("hidden") for consistency. This includes modals, buttons, and error divs. The codebase follows this pattern rather than using component APIs for hiding elements during game transitions.

Applied to files:

  • src/server/Master.ts
🧬 Code graph analysis (1)
src/server/Master.ts (1)
src/core/Schemas.ts (1)
  • GameInfo (130-136)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Deploy to openfront.dev
🔇 Additional comments (3)
src/server/Master.ts (3)

8-8: LGTM!

Clean import of WebSocket types from the ws package.


215-217: LGTM!

Using res.json() sets the correct Content-Type header and serializes the object cleanly. This maintains compatibility with clients that fall back to HTTP polling.


283-288: LGTM!

Updates the lobbies data and broadcasts to all connected clients in a single call. Clean and straightforward.

Copy link
Collaborator

@evanpelle evanpelle left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@evanpelle evanpelle merged commit 3dcd38a into main Jan 1, 2026
11 checks passed
@evanpelle evanpelle deleted the websocket branch January 1, 2026 17:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Backend Server-side features and systems - lobbies, matchmaking, accounts, APIs, etc. Performance Performance optimization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants