Skip to content

fix(matchmaking): widen the join window and cancel short-handed games - #4762

Merged
evanpelle merged 5 commits into
mainfrom
fix/matchmaking-start-deadline
Jul 28, 2026
Merged

fix(matchmaking): widen the join window and cancel short-handed games#4762
evanpelle merged 5 commits into
mainfrom
fix/matchmaking-start-deadline

Conversation

@evanpelle

@evanpelle evanpelle commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Problem

Sometimes a matchmade player never makes it into the game — the classic symptom is a ranked 2v2 starting with 3 players (then voided by the WinCheck cancellation), or a 1v1 decided by a walkover the absent player never contested.

Root cause: matchmade games were created with startsAt = now + 7s, and start() freezes gameStartInfo.players from whoever is connected ~9s after match-assignment (7s + the 2s prestart gap + tick granularity). The client join pipeline can't reliably fit in that window:

  • The exists-poll interval waits a full second before its first check.
  • The prefetched Turnstile token has a 3-minute TTL, and queue waits routinely exceed it — so a fresh Cloudflare challenge often runs inside the critical window.
  • Join auth is three sequential API calls server-side (verifyClientToken, verifyJoin, getUserMe).
  • Background tabs get their timers throttled (up to 1/min after 5 min hidden in Chrome), and locked phones suspend JS entirely — for those players the window is unwinnable.

Changes

1. Widen the deadline: 7s → 15s (Worker.ts). Full lobbies are NOT delayed: hasReachedMaxPlayerCount flips the game to Active as soon as everyone has joined, so the deadline only matters for underfilled lobbies — raising it costs nothing in the common case.

2. Cancel instead of starting short-handed (GameServer.cancelShortHandedMatch, wired in GameManager.tick). If the deadline still arrives with fewer than maxPlayers connected on a game whose rankedType is exactly OneVOne or TwoVTwo (explicit gate — a future ranked type must opt in rather than inherit pre-start cancellation), the game is cancelled: connected players are kicked with a new kick_reason.match_cancelled slug, mirroring the existing kick_reason.host_left flow (_hasEnded set, phase() reports Finished, GameManager prunes next tick, no archive).

3. Auto-requeue the players who did connect — wired through a new matchmaking-requeue document event handled by Main (no direct DOM coupling). On kick_reason.match_cancelled the client dispatches leave-lobby to tear down the dead lobby, then matchmaking-requeue; Main calls MatchmakingModal.requeue(), which puts the modal — still open on "waiting for game" — straight back into the searching state, reconnecting to the queue in the same mode (1v1 or 2v2). A non-blocking toast explains why; a blocking alert would keep the player out of the queue until dismissed. If the modal was closed in the meantime, the modeless dispatch is a no-op — a player who left the queue isn't forced back in. WinModal's post-game requeue button now fires the same event (with the mode), so Main owns the whole mechanism; its behavior is unchanged (reload with the /?requeue param, which the page consumes to reopen the queue after teardown).

This is deliberately server-side, not core: the game never starts, so there is no simulation to decide anything — it's lobby lifecycle, complementing the core-side WinCheck cancellation (#4655) which covers players who connect but never spawn.

Notes for reviewers

  • The DO consumed a ranked play for all matched players at assignment time, and the auto-requeue can consume another one on the next assignment. A cancelled game archives no record, so the API has nothing to void — refunding the no-show's teammates needs an infra-side hook. Flagging rather than solving here.
  • Old clients receiving the new slug fall into the generic error-modal branch — functional but uglier, transitional only.

Test plan

  • New tests/server/MatchmakingCancel.test.ts: cancels short-handed 2v2 (kick message + close + Finished phase), cancels 1v1 no-show, leaves full ranked games, non-ranked games, and unknown ranked types alone
  • Full tests/server suite passes (292 tests), ESLint/Prettier/tsc --noEmit clean
  • E2E against the real stack (dev app + wrangler dev API): new npm run test:matchmaking:cancel harness — 4 browsers queue for 2v2, one has its /exists polls blocked; the game is cancelled 17.1s after assignment (re-verified 9/9 after the event-based requeue wiring), the three connected players get the toast + leave-lobby and are automatically requeued (modal back to searching), the no-show is untouched, and the game is pruned without an archive. 9/9 checks pass.
  • Positive control: stock MM_MODE=2v2 npm run test:matchmaking:e2e still passes (8/8) — a full lobby starts normally and rides into the game with a deterministic 2v2 split.

🤖 Generated with Claude Code

Matchmade games froze their roster ~9s after match-assignment (7s startsAt
+ 2s prestart gap), while the client join pipeline (exists poll, Turnstile
re-challenge, join auth) takes 4-6s on a good day — and far longer from a
throttled background tab. Players who missed the window were left out of
gameStartInfo, so ranked games started short-handed: a void 2v2 or an
uncontested 1v1 walkover.

Two changes:
- Give players 15s instead of 7s to connect. Full lobbies are unaffected:
  hasReachedMaxPlayerCount flips the game Active as soon as everyone joins.
- If the deadline still arrives short-handed, cancel the game instead of
  starting it: kick the connected players with kick_reason.match_cancelled
  (alert + leave-lobby + matchmaking modal close client-side) and end the
  unstarted game without archiving.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 09cbda4d-c863-4b96-92ef-2268f8d295b1

📥 Commits

Reviewing files that changed from the base of the PR and between 0104e22 and 29b6b32.

📒 Files selected for processing (1)
  • src/client/Matchmaking.ts

Walkthrough

Ranked matchmade games now cancel when players are missing at the start deadline. Connected clients receive a localized message, leave the lobby, and re-enter matchmaking. Unit and end-to-end tests cover cancellation, requeue, pruning, and no-show behavior.

Changes

Match Cancellation Flow

Layer / File(s) Summary
Server cancellation and lifecycle wiring
src/server/GameServer.ts, src/server/GameManager.ts, src/server/Worker.ts
Short-handed ranked matches use a 15-second roster deadline and are cancelled before prestart logic.
Client cancellation feedback
resources/lang/en.json, src/client/ClientGameRunner.ts, src/client/Matchmaking.ts, src/client/Main.ts
Clients show the cancellation message, dispatch the leave event, reset matchmaking state, and reconnect.
Requeue event integration
src/client/hud/layers/WinModal.ts
Win modal requeue actions emit a typed event with the selected mode.
Cancellation validation and harness
tests/server/MatchmakingCancel.test.ts, tests/matchmaking/*, package.json
Tests validate cancellation rules, requeue behavior, no-show handling, game pruning, and logging.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MatchmakingWorker
  participant GameManager
  participant GameServer
  participant ClientGameRunner
  participant Main
  participant MatchmakingModal
  MatchmakingWorker->>GameServer: Create match with 15-second roster deadline
  GameManager->>GameServer: Check short-handed match at start deadline
  GameServer->>ClientGameRunner: Send match-cancelled kick reason
  ClientGameRunner->>Main: Dispatch matchmaking-requeue
  Main->>MatchmakingModal: Call requeue()
  MatchmakingModal->>MatchmakingWorker: Start matchmaking connection
Loading

Possibly related PRs

Suggested labels: UI/UX

Suggested reviewers: celant

Poem

A player missed the starting bell,
The match now ends before the swell.
A gentle note lights up the screen,
Then back to queue, calm and clean.
No ELO loss, no tangled queue.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: a longer join window and cancellation of short-handed matches.
Description check ✅ Passed The description is directly related to the patch and explains the matchmaking window, cancellation, and requeue behavior.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/server/GameManager.ts (1)

141-153: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Recheck ranked attendance immediately before start().

After prestart(), hasStarted() becomes true. If a player disconnects during the two-second delay, the callback still starts the ranked game short-handed because cancellation is never re-evaluated.

Proposed fix
          setTimeout(() => {
            try {
-              game.start();
+              if (!game.cancelShortHandedMatch()) {
+                game.start();
+              }
            } catch (error) {
              this.log.error(`error starting game ${id}: ${error}`);
            }
          }, 2000);

Add a GameManager-level test for a full ranked lobby that loses a player after prestart but before the delayed start.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/GameManager.ts` around lines 141 - 153, Recheck ranked attendance
in the delayed callback before calling game.start(), using the existing
GameManager/game cancellation logic to cancel a match that loses a player after
prestart instead of starting it short-handed. Preserve normal startup for full
lobbies, and add a GameManager-level test covering a full ranked lobby that
loses a player during the delay.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/server/GameManager.ts`:
- Around line 141-153: Recheck ranked attendance in the delayed callback before
calling game.start(), using the existing GameManager/game cancellation logic to
cancel a match that loses a player after prestart instead of starting it
short-handed. Preserve normal startup for full lobbies, and add a
GameManager-level test covering a full ranked lobby that loses a player during
the delay.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 89738c93-ee28-4584-9538-eaae8e21c31c

📥 Commits

Reviewing files that changed from the base of the PR and between 396f032 and 35caf4f.

📒 Files selected for processing (6)
  • resources/lang/en.json
  • src/client/ClientGameRunner.ts
  • src/server/GameManager.ts
  • src/server/GameServer.ts
  • src/server/Worker.ts
  • tests/server/MatchmakingCancel.test.ts

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 28, 2026
When the server cancels a matchmade game because a player never connected,
the players who did connect are put straight back into the queue: the
matchmaking modal (still open on "waiting for game") resets to searching
and reconnects in the same mode, with a toast explaining why. A blocking
alert would keep them out of the queue until dismissed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/client/Matchmaking.ts`:
- Around line 124-133: Update the requeue/reset flow in Matchmaking to detach or
invalidate the previous socket before resetting intentionalClose and gameID and
calling connect(). Ensure any delayed onclose handler from the old socket cannot
treat its closure as unexpected or initiate another connection.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8cf80586-db5f-4592-a6f2-dcafb1cc7ce9

📥 Commits

Reviewing files that changed from the base of the PR and between 35caf4f and 160ab59.

📒 Files selected for processing (2)
  • src/client/ClientGameRunner.ts
  • src/client/Matchmaking.ts

Comment thread src/client/Matchmaking.ts
@github-project-automation github-project-automation Bot moved this from Triage to Development in OpenFront Release Management Jul 28, 2026
Four browser players queue for 2v2 against the real API worker; one has
its /exists polls blocked so it never connects to the created game.
Asserts the server cancels at the start deadline instead of starting
3-handed, the connected players get the toast + leave-lobby and are
requeued automatically, the no-show is untouched, and the game is pruned
without an archive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@evanpelle evanpelle added this to the v33 milestone Jul 28, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/matchmaking/e2e-cancel.mjs`:
- Around line 226-232: Replace the fixed 2-second delay and one-shot
fetchGameInfo check in the cancelled-game pruning assertion with the file’s
existing waitFor polling helper, using a generous timeout and polling until
fetchGameInfo(gameId) returns null. Preserve the existing check message and
cancellation-pruning expectation.
- Around line 234-238: Update the final cancellation-log check in the e2e test
to read the actual server log for this test, or catch read failures and make the
check evaluate false so cleanup and c.finish() still run. Also match the
complete emitted cancellation log message rather than only the shorter
substring, using the existing c.check flow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fe2aa701-a865-4ba3-b818-98e463e474df

📥 Commits

Reviewing files that changed from the base of the PR and between 160ab59 and 0f1b1bf.

📒 Files selected for processing (3)
  • package.json
  • tests/matchmaking/README.md
  • tests/matchmaking/e2e-cancel.mjs

Comment on lines +226 to +232
// The cancelled game must be gone from the game server (pruned, never
// started, nothing archived).
await new Promise((r) => setTimeout(r, 2000));
c.check(
"cancelled game pruned from the server",
(await fetchGameInfo(gameId)) === null,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Pruning check uses a fixed sleep instead of polling.

Every other multi-step assertion in this file uses waitFor with a generous timeout to avoid flakiness, but the pruning check relies on a single fixed 2s sleep + one-shot fetchGameInfo call. If pruning takes slightly longer under load, this fails spuriously instead of surfacing a clear timeout message.

♻️ Suggested fix: poll with waitFor like the rest of the file
-  await new Promise((r) => setTimeout(r, 2000));
-  c.check(
-    "cancelled game pruned from the server",
-    (await fetchGameInfo(gameId)) === null,
-  );
+  const pruned = await waitFor(async () => (await fetchGameInfo(gameId)) === null, {
+    timeoutMs: 10000,
+    intervalMs: 1000,
+    label: "cancelled game to be pruned",
+  }).catch(() => false);
+  c.check("cancelled game pruned from the server", pruned);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// The cancelled game must be gone from the game server (pruned, never
// started, nothing archived).
await new Promise((r) => setTimeout(r, 2000));
c.check(
"cancelled game pruned from the server",
(await fetchGameInfo(gameId)) === null,
);
// The cancelled game must be gone from the game server (pruned, never
// started, nothing archived).
const pruned = await waitFor(async () => (await fetchGameInfo(gameId)) === null, {
timeoutMs: 10000,
intervalMs: 1000,
label: "cancelled game to be pruned",
}).catch(() => false);
c.check("cancelled game pruned from the server", pruned);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/matchmaking/e2e-cancel.mjs` around lines 226 - 232, Replace the fixed
2-second delay and one-shot fetchGameInfo check in the cancelled-game pruning
assertion with the file’s existing waitFor polling helper, using a generous
timeout and polling until fetchGameInfo(gameId) returns null. Preserve the
existing check message and cancellation-pruning expectation.

Comment on lines +234 to +238
const devLog = readFileSync("/tmp/dev.log", "utf8");
c.check(
"server logged the cancellation",
devLog.includes("cancelling matchmade game"),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the dev.log convention and the exact cancellation log string.
rg -n "dev\.log" tests/matchmaking -A2 -B2
rg -n "cancelling matchmade game" src/server

Repository: openfrontio/OpenFrontIO

Length of output: 564


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- candidate file outline/size ---\n'
wc -l tests/matchmaking/e2e-cancel.mjs
printf '\n--- relevant test block ---\n'
sed -n '1,270p' tests/matchmaking/e2e-cancel.mjs | cat -n

printf '\n--- exact server log context ---\n'
sed -n '1055,1085p' src/server/GameServer.ts | cat -n

printf '\n--- other dev.log reads in repo ---\n'
rg -n "readFileSync\\((?:\"|')/tmp/dev\\.log|\"/tmp/dev\\.log|'/tmp/dev\\.log" .

Repository: openfrontio/OpenFrontIO

Length of output: 252


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf -- '--- candidate file outline/size ---\n'
wc -l tests/matchmaking/e2e-cancel.mjs

printf -- '\n--- relevant test block ---\n'
sed -n '1,270p' tests/matchmaking/e2e-cancel.mjs | cat -n

printf -- '\n--- exact server log context ---\n'
sed -n '1055,1085p' src/server/GameServer.ts | cat -n

printf -- '\n--- other dev.log reads in repo ---\n'
rg -n "readFileSync\\((?:\"|')/tmp/dev\\.log|\"/tmp/dev\\.log|'/tmp/dev\\.log" .

Repository: openfrontio/OpenFrontIO

Length of output: 10891


Make the final log check fail instead of throwing

readFileSync("/tmp/dev.log", "utf8") can throw if the log does not exist or the path convention changes, which skips the finally cleanup and c.finish() summary. Capture the log from the actual server log produced for this test, or wrap this read in try/catch so the check reports false instead of crashing. The expected server log text is longer than "cancelling matchmade game"; use the full emitted string if this is intentional.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/matchmaking/e2e-cancel.mjs` around lines 234 - 238, Update the final
cancellation-log check in the e2e test to read the actual server log for this
test, or catch read failures and make the check evaluate false so cleanup and
c.finish() still run. Also match the complete emitted cancellation log message
rather than only the shorter substring, using the existing c.check flow.

evanpelle and others added 2 commits July 28, 2026 15:35
Replace the duck-typed matchmaking-modal lookup in ClientGameRunner with a
matchmaking-requeue document event handled by Main, and route WinModal's
post-game requeue button through the same event so Main owns the mechanism
(in-place modal rejoin pre-start; reload with the requeue param after a
finished game). cancelShortHandedMatch now checks rankedType is exactly
OneVOne/TwoVTwo so a future ranked type must opt in to pre-start
cancellation rather than inherit it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
requeue()/onOpen() reset intentionalClose and gameID before calling
connect(), so a delayed close event from the previous socket would pass
the unexpected-close guard and schedule a duplicate connection — which
the server then resolves by kicking the newer socket as "replaced by
newer connection", closing the modal on the innocent player. connect()
now detaches and closes any previous socket and clears a pending
reconnect timer before dialing, serializing connection attempts.

Also reset the queue-size display on requeue so a stale count from the
previous queue session doesn't show until the first broadcast.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@evanpelle
evanpelle merged commit 74d1978 into main Jul 28, 2026
13 checks passed
@evanpelle
evanpelle deleted the fix/matchmaking-start-deadline branch July 28, 2026 22:51
@github-project-automation github-project-automation Bot moved this from Development to Complete in OpenFront Release Management Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Complete

Development

Successfully merging this pull request may close these issues.

1 participant