| null = null;
private reconnectAttempts = 0;
private intentionalClose = false;
// Which queue to join; set by Main from the open-matchmaking event
// before the modal opens.
public mode: "1v1" | "2v2" = "1v1";
@state() private connected = false;
@state() private socket: WebSocket | null = null;
@state() private gameID: string | null = null;
private elo: number | string = "...";
constructor() {
super();
this.id = "page-matchmaking";
}
createRenderRoot() {
return this;
}
protected renderHeaderSlot() {
return modalHeader({
title: translateText(
this.mode === "2v2"
? "matchmaking_modal.title_2v2"
: "matchmaking_modal.title",
),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
});
}
protected renderBody() {
const eloDisplay = html`
${translateText("matchmaking_modal.elo", { elo: this.elo })}
`;
return html`
${eloDisplay} ${this.renderInner()}
`;
}
private renderInner() {
if (!this.connected) {
return this.renderLoadingSpinner(
translateText("matchmaking_modal.connecting"),
"blue",
);
}
if (this.gameID === null) {
return this.renderLoadingSpinner(
translateText("matchmaking_modal.searching"),
"green",
);
} else {
return this.renderLoadingSpinner(
translateText("matchmaking_modal.waiting_for_game"),
"yellow",
);
}
}
private async connect() {
// A pending join timer from a previous socket must not fire on this one.
if (this.connectTimeout) {
clearTimeout(this.connectTimeout);
this.connectTimeout = null;
}
this.socket = new WebSocket(
`${ClientEnv.jwtIssuer()}/matchmaking/join?instance_id=${encodeURIComponent(ClientEnv.instanceId())}&mode=${this.mode}`,
);
this.socket.onopen = async () => {
console.log("Connected to matchmaking server");
this.connectTimeout = setTimeout(async () => {
if (this.socket?.readyState !== WebSocket.OPEN) {
console.warn("[Matchmaking] socket not ready");
return;
}
// Set a delay so the user can see the "connecting" message,
// otherwise the "searching" message will be shown immediately.
// Also wait so people who back out immediately aren't added
// to the matchmaking queue.
this.socket.send(
JSON.stringify({
type: "join",
jwt: await getPlayToken(),
}),
);
this.connected = true;
this.requestUpdate();
}, 2000);
};
this.socket.onmessage = (event) => {
console.log(event.data);
const data = JSON.parse(event.data);
if (data.type === "match-assignment") {
this.intentionalClose = true;
this.socket?.close();
console.log(`matchmaking: got game ID: ${data.gameId}`);
this.gameID = data.gameId;
this.gameCheckInterval = setInterval(() => this.checkGame(), 1000);
}
};
this.socket.onerror = (event: Event) => {
console.error("WebSocket error occurred:", event);
};
this.socket.onclose = (event: CloseEvent) => {
console.log(
`Matchmaking server closed connection: code=${event.code} reason=${event.reason}`,
);
if (this.intentionalClose || this.gameID !== null) {
return;
}
if (event.code === 1000) {
// A newer connection for this account (e.g. a second tab) took the
// queue slot; this socket was replaced. Do not retry.
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: translateText("matchmaking_modal.replaced"),
color: "red",
duration: 5000,
},
}),
);
this.close();
return;
}
// 1008: the jwt was rejected — getPlayToken() refreshes expired tokens,
// so rejoining sends a fresh one. Anything else is a server
// restart/deploy; the queue is in-memory only, so rejoin. Back off in
// case the failure repeats.
this.connected = false;
const delay = Math.min(1000 * 2 ** this.reconnectAttempts++, 15000);
this.reconnectTimeout = setTimeout(() => this.connect(), delay);
};
}
protected async onOpen(): Promise {
const userMe = await getUserMe();
// Early return if modal was closed during async operation
if (!this.isModalOpen) {
return;
}
// CrazyGames players authenticate through the SDK rather than a linked
// Discord/Google/email account, so a signed-in CrazyGames user counts as
// logged in for ranked.
const crazyGamesSignedIn =
crazyGamesSDK.isOnCrazyGames() &&
(await crazyGamesSDK.getUserProfile()) !== null;
if (!this.isModalOpen) {
return;
}
if (
userMe === false ||
(!hasLinkedAccount(userMe) && !crazyGamesSignedIn)
) {
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: translateText("matchmaking_button.must_login"),
color: "red",
duration: 3000,
},
}),
);
this.close();
window.showPage?.("page-account");
return;
}
const row =
this.mode === "2v2"
? userMe.player.leaderboard?.twoVtwo
: userMe.player.leaderboard?.oneVone;
this.elo = row?.elo ?? translateText("matchmaking_modal.no_elo");
this.connected = false;
this.gameID = null;
this.intentionalClose = false;
this.reconnectAttempts = 0;
this.connect();
}
protected onClose(): void {
this.connected = false;
this.intentionalClose = true;
this.socket?.close();
if (this.connectTimeout) {
clearTimeout(this.connectTimeout);
this.connectTimeout = null;
}
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
if (this.gameCheckInterval) {
clearInterval(this.gameCheckInterval);
this.gameCheckInterval = null;
}
}
private async checkGame() {
if (this.gameID === null) {
return;
}
const url = `/${ClientEnv.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,
source: "matchmaking",
} as JoinLobbyEvent,
bubbles: true,
composed: true,
}),
);
}
}
@customElement("matchmaking-button")
export class MatchmakingButton extends LitElement {
@state() private isLoggedIn = false;
constructor() {
super();
}
async connectedCallback() {
super.connectedCallback();
// Listen for user authentication changes
document.addEventListener("userMeResponse", (event: Event) => {
const customEvent = event as CustomEvent;
if (customEvent.detail) {
const userMeResponse = customEvent.detail as UserMeResponse | false;
this.isLoggedIn = hasLinkedAccount(userMeResponse);
}
});
}
createRenderRoot() {
return this;
}
render() {
return this.isLoggedIn
? html`
`
: html`
`;
}
private handleLoggedInClick() {
document.dispatchEvent(new CustomEvent("open-matchmaking"));
}
private handleLoggedOutClick() {
window.showPage?.("page-account");
}
}