feature: basic matchmaking (#2227)

## Description:

Implement a basic matchmaking modal that connects to the api service and
waits for a game id. It then waits until the game starts and connects to
it.

Workers use long polling to check in with the matchmaking server and
receive player assignments.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] 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:

evan

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Evan
2025-10-21 14:08:07 -07:00
committed by GitHub
parent dddf54be0b
commit 4ada4c7375
10 changed files with 320 additions and 9 deletions
+6
View File
@@ -222,6 +222,12 @@
"teams_Quads": "Quads (teams of 4)",
"teams": "{num} teams"
},
"matchmaking_modal": {
"title": "Matchmaking",
"connecting": "Connecting to matchmaking server...",
"searching": "Searching for game...",
"waiting_for_game": "Waiting for game to start..."
},
"username": {
"enter_username": "Enter your username",
"not_string": "Username must be a string.",
+15 -1
View File
@@ -22,6 +22,8 @@ import { JoinPrivateLobbyModal } from "./JoinPrivateLobbyModal";
import "./LangSelector";
import { LangSelector } from "./LangSelector";
import { LanguageModal } from "./LanguageModal";
import "./Matchmaking";
import { MatchmakingModal } from "./Matchmaking";
import { NewsModal } from "./NewsModal";
import "./PublicLobby";
import { PublicLobby } from "./PublicLobby";
@@ -100,6 +102,7 @@ class Client {
private userSettings: UserSettings = new UserSettings();
private patternsModal: TerritoryPatternsModal;
private tokenLoginModal: TokenLoginModal;
private matchmakingModal: MatchmakingModal;
private gutterAds: GutterAds;
@@ -256,6 +259,16 @@ class Client {
console.warn("Token login modal element not found");
}
this.matchmakingModal = document.querySelector(
"matchmaking-modal",
) as MatchmakingModal;
if (
!this.matchmakingModal ||
!(this.matchmakingModal instanceof MatchmakingModal)
) {
console.warn("Matchmaking modal element not found");
}
const onUserMe = async (userMeResponse: UserMeResponse | false) => {
document.dispatchEvent(
new CustomEvent("userMeResponse", {
@@ -598,6 +611,7 @@ class Client {
"flag-input-modal",
"account-button",
"token-login",
"matchmaking-modal",
].forEach((tag) => {
const modal = document.querySelector(tag) as HTMLElement & {
close?: () => void;
@@ -697,7 +711,7 @@ document.addEventListener("DOMContentLoaded", () => {
});
// WARNING: DO NOT EXPOSE THIS ID
function getPlayToken(): string {
export function getPlayToken(): string {
const result = isLoggedIn();
if (result !== false) return result.token;
return getPersistentIDFromCookie();
+193
View File
@@ -0,0 +1,193 @@
import { html, LitElement } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { generateID } from "../core/Util";
import "./components/Difficulties";
import "./components/PatternButton";
import { getPlayToken, JoinLobbyEvent } from "./Main";
import { translateText } from "./Utils";
@customElement("matchmaking-modal")
export class MatchmakingModal extends LitElement {
private gameCheckInterval: ReturnType<typeof setInterval> | null = null;
private connected = false;
@state() private socket: WebSocket | null = null;
@state() private gameID: string | null = null;
@query("o-modal") private modalEl!: HTMLElement & {
open: () => void;
close: () => void;
};
constructor() {
super();
}
createRenderRoot() {
return this;
}
render() {
return html`
<o-modal
id="matchmaking-modal"
title="${translateText("matchmaking_modal.title")}"
>
${this.renderInner()}
</o-modal>
`;
}
private renderInner() {
if (!this.connected) {
return html`${translateText("matchmaking_modal.connecting")}`;
}
if (this.gameID === null) {
return html`${translateText("matchmaking_modal.searching")}`;
} else {
return html`${translateText("matchmaking_modal.waiting_for_game")}`;
}
}
private async connect() {
const config = await getServerConfigFromClient();
this.socket = new WebSocket(`${config.jwtIssuer()}/matchmaking/join`);
this.socket.onopen = () => {
console.log("Connected to matchmaking server");
setTimeout(() => {
// Set a delay so the user can see the "connecting" message,
// otherwise the "searching" message will be shown immediately.
this.connected = true;
this.requestUpdate();
}, 1000);
this.socket?.send(
JSON.stringify({
type: "auth",
playToken: getPlayToken(),
}),
);
};
this.socket.onmessage = (event) => {
console.log(event.data);
const data = JSON.parse(event.data);
if (data.type === "match-assignment") {
this.socket?.close();
console.log(`matchmaking: got game ID: ${data.gameId}`);
this.gameID = data.gameId;
}
};
this.socket.onerror = (event: ErrorEvent) => {
console.error("WebSocket error occurred:", event);
};
this.socket.onclose = (event) => {
console.log("Matchmaking server closed connection");
};
}
public close() {
this.connected = false;
this.socket?.close();
this.modalEl?.close();
if (this.gameCheckInterval) {
clearInterval(this.gameCheckInterval);
this.gameCheckInterval = null;
}
}
public async open() {
this.modalEl?.open();
this.requestUpdate();
this.connect();
this.gameCheckInterval = setInterval(() => this.checkGame(), 3000);
}
private async checkGame() {
if (this.gameID === null) {
return;
}
const config = await getServerConfigFromClient();
const url = `/${config.workerPath(this.gameID)}/api/game/${this.gameID}/exists`;
const response = await fetch(url, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
const gameInfo = await response.json();
if (response.status !== 200) {
console.error(`Error checking game ${this.gameID}: ${response.status}`);
return;
}
if (!gameInfo.exists) {
console.info(`Game ${this.gameID} does not exist or hasn't started yet`);
return;
}
if (this.gameCheckInterval) {
clearInterval(this.gameCheckInterval);
this.gameCheckInterval = null;
}
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
gameID: this.gameID,
clientID: generateID(),
} as JoinLobbyEvent,
bubbles: true,
composed: true,
}),
);
}
}
@customElement("matchmaking-button")
export class MatchmakingButton extends LitElement {
@query("matchmaking-modal") private matchmakingModal: MatchmakingModal;
@state() private matchmakingEnabled = false;
constructor() {
super();
}
async connectedCallback() {
super.connectedCallback();
const config = await getServerConfigFromClient();
this.matchmakingEnabled = config.enableMatchmaking();
}
createRenderRoot() {
return this;
}
render() {
if (!this.matchmakingEnabled) {
return html``;
}
return html`
<div class="z-[9999]">
<button
@click="${this.open}"
class="w-full h-16 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-2xl hover:shadow-3xl transition-all duration-200 flex items-center justify-center text-xl focus:outline-none focus:ring-4 focus:ring-blue-500 focus:ring-offset-4"
title="${translateText("matchmaking_modal.title")}"
>
Matchmaking
</button>
</div>
<matchmaking-modal></matchmaking-modal>
`;
}
private open() {
this.matchmakingModal?.open();
}
public close() {
this.matchmakingModal?.close();
this.requestUpdate();
}
}
+3
View File
@@ -221,6 +221,9 @@
<div>
<public-lobby class="block"></public-lobby>
</div>
<div>
<matchmaking-button class="w-[20%] md:w-[15%]"></matchmaking-button>
</div>
<div class="container__row container__row--equal">
<o-button
id="host-lobby-button"
+1
View File
@@ -63,6 +63,7 @@ export interface ServerConfig {
cloudflareCredsPath(): string;
stripePublishableKey(): string;
allowedFlares(): string[] | undefined;
enableMatchmaking(): boolean;
}
export interface NukeMagnitude {
+3
View File
@@ -213,6 +213,9 @@ export abstract class DefaultServerConfig implements ServerConfig {
workerPortByIndex(index: number): number {
return 3001 + index;
}
enableMatchmaking(): boolean {
return false;
}
}
export class DefaultConfig implements Config {
+6 -2
View File
@@ -71,6 +71,8 @@ const TEAM_COUNTS = [
export class MapPlaylist {
private mapsPlaylist: MapWithMode[] = [];
constructor(private disableTeams: boolean = false) {}
public gameConfig(): GameConfig {
const { map, mode } = this.getNextMap();
@@ -135,8 +137,10 @@ export class MapPlaylist {
if (!this.addNextMap(this.mapsPlaylist, ffa, GameMode.FFA)) {
return false;
}
if (!this.addNextMap(this.mapsPlaylist, team, GameMode.Team)) {
return false;
if (!this.disableTeams) {
if (!this.addNextMap(this.mapsPlaylist, team, GameMode.Team)) {
return false;
}
}
}
return true;
-5
View File
@@ -291,11 +291,6 @@ async function schedulePublicGame(playlist: MapPlaylist) {
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// SPA fallback route
app.get("*", function (req, res) {
res.sendFile(path.join(__dirname, "../../static/index.html"));
+90 -1
View File
@@ -11,11 +11,12 @@ import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { GameType } from "../core/game/Game";
import {
ClientMessageSchema,
GameID,
ID,
PartialGameRecordSchema,
ServerErrorMessage,
} from "../core/Schemas";
import { replacer } from "../core/Util";
import { generateID, replacer } from "../core/Util";
import { CreateGameInputSchema, GameInputSchema } from "../core/WorkerSchemas";
import { archive, finalizeGameRecord } from "./Archive";
import { Client } from "./Client";
@@ -23,6 +24,7 @@ import { GameManager } from "./GameManager";
import { getUserMe, verifyClientToken } from "./jwt";
import { logger } from "./Logger";
import { MapPlaylist } from "./MapPlaylist";
import { PrivilegeRefresher } from "./PrivilegeRefresher";
import { initWorkerMetrics } from "./WorkerMetrics";
@@ -30,11 +32,24 @@ const config = getServerConfigFromServer();
const workerId = parseInt(process.env.WORKER_ID ?? "0");
const log = logger.child({ comp: `w_${workerId}` });
const playlist = new MapPlaylist(true);
// Worker setup
export async function startWorker() {
log.info(`Worker starting...`);
if (config.enableMatchmaking()) {
log.info("Starting matchmaking");
setTimeout(
() => {
pollLobby(gm);
},
1000 + Math.random() * 2000,
);
} else {
log.info("Matchmaking disabled");
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -450,3 +465,77 @@ export async function startWorker() {
log.error(`unhandled rejection at:`, promise, "reason:", reason);
});
}
async function pollLobby(gm: GameManager) {
try {
const url = `${config.jwtIssuer() + "/matchmaking/checkin"}`;
const gameId = generateGameIdForWorker();
if (gameId === null) {
log.warn(`Failed to generate game ID for worker ${workerId}`);
return;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 20000);
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": config.apiKey(),
},
body: JSON.stringify({
id: workerId,
gameId: gameId,
ccu: gm.activeClients(),
}),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
log.warn(
`Failed to poll lobby: ${response.status} ${response.statusText}`,
);
return;
}
const data = await response.json();
log.info(`Lobby poll successful:`, data);
if (data.assignment) {
// TODO: Only allow specified players to join the game.
console.log(`Creating game ${gameId}`);
const game = gm.createGame(gameId, playlist.gameConfig());
setTimeout(() => {
// Wait a few seconds to allow clients to connect.
console.log(`Starting game ${gameId}`);
game.start();
}, 5000);
}
} catch (error) {
log.error(`Error polling lobby:`, error);
} finally {
setTimeout(
() => {
pollLobby(gm);
},
5000 + Math.random() * 1000,
);
}
}
// TODO: This is a hack to generate a game ID for the worker.
// It should be replaced with a more robust solution.
function generateGameIdForWorker(): GameID | null {
let attempts = 1000;
while (attempts > 0) {
const gameId = generateID();
if (workerId === config.workerIndex(gameId)) {
return gameId;
}
attempts--;
}
log.warn(`Failed to generate game ID for worker ${workerId}`);
return null;
}
+3
View File
@@ -4,6 +4,9 @@ import { GameMapType } from "../../src/core/game/Game";
import { GameID } from "../../src/core/Schemas";
export class TestServerConfig implements ServerConfig {
enableMatchmaking(): boolean {
throw new Error("Method not implemented.");
}
apiKey(): string {
throw new Error("Method not implemented.");
}