diff --git a/src/client/GameInfoModal.ts b/src/client/GameInfoModal.ts
index 917665a930..787f5820d4 100644
--- a/src/client/GameInfoModal.ts
+++ b/src/client/GameInfoModal.ts
@@ -180,7 +180,7 @@ export class GameInfoModal extends LitElement {
try {
const mapType = gameMap as GameMapType;
const data = terrainMapFileLoader.getMapData(mapType);
- this.mapImage = await data.webpPath();
+ this.mapImage = data.webpPath;
} catch (error) {
console.error("Failed to load map image:", error);
}
diff --git a/src/client/GameModeSelector.ts b/src/client/GameModeSelector.ts
new file mode 100644
index 0000000000..ad0dd34bd1
--- /dev/null
+++ b/src/client/GameModeSelector.ts
@@ -0,0 +1,363 @@
+import { html, LitElement, nothing, type TemplateResult } from "lit";
+import { customElement, state } from "lit/decorators.js";
+import {
+ Duos,
+ GameMapType,
+ GameMode,
+ HumansVsNations,
+ PublicGameModifiers,
+ Quads,
+ Trios,
+} from "../core/game/Game";
+import { PublicGameInfo, PublicGames } from "../core/Schemas";
+import { HostLobbyModal } from "./HostLobbyModal";
+import { JoinLobbyModal } from "./JoinLobbyModal";
+import { PublicLobbySocket } from "./LobbySocket";
+import { JoinLobbyEvent } from "./Main";
+import { SinglePlayerModal } from "./SinglePlayerModal";
+import { terrainMapFileLoader } from "./TerrainMapFileLoader";
+import { getMapName, renderDuration, translateText } from "./Utils";
+
+const CARD_BG = "bg-[color-mix(in_oklab,var(--frenchBlue)_70%,black)]";
+
+@customElement("game-mode-selector")
+export class GameModeSelector extends LitElement {
+ @state() private lobbies: PublicGames | null = null;
+ private serverTimeOffset: number = 0;
+
+ private lobbySocket = new PublicLobbySocket((lobbies) =>
+ this.handleLobbiesUpdate(lobbies),
+ );
+
+ createRenderRoot() {
+ return this;
+ }
+
+ /**
+ * Validates username input and shows error message if invalid.
+ * Returns true if valid, false otherwise.
+ */
+ private validateUsername(): boolean {
+ const usernameInput = document.querySelector("username-input") as any;
+ if (usernameInput?.isValid?.() === false) {
+ window.dispatchEvent(
+ new CustomEvent("show-message", {
+ detail: {
+ message: usernameInput.validationError,
+ color: "red",
+ duration: 3000,
+ },
+ }),
+ );
+ return false;
+ }
+ return true;
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.lobbySocket.start();
+ }
+
+ disconnectedCallback() {
+ this.stop();
+ super.disconnectedCallback();
+ }
+
+ public stop() {
+ this.lobbySocket.stop();
+ }
+
+ private handleLobbiesUpdate(lobbies: PublicGames) {
+ this.lobbies = lobbies;
+ this.serverTimeOffset = lobbies.serverTime - Date.now();
+ document.dispatchEvent(
+ new CustomEvent("public-lobbies-update", {
+ detail: { payload: lobbies },
+ }),
+ );
+ this.requestUpdate();
+ }
+
+ render() {
+ const ffa = this.lobbies?.games?.["ffa"]?.[0];
+ const teams = this.lobbies?.games?.["team"]?.[0];
+ const special = this.lobbies?.games?.["special"]?.[0];
+
+ return html`
+
+ ${ffa ? this.renderLobbyCard(ffa, this.getLobbyTitle(ffa)) : nothing}
+ ${teams
+ ? this.renderLobbyCard(teams, this.getLobbyTitle(teams))
+ : nothing}
+ ${special ? this.renderSpecialLobbyCard(special) : nothing}
+ ${this.renderQuickActionsSection()}
+
+ `;
+ }
+
+ private renderSpecialLobbyCard(lobby: PublicGameInfo) {
+ const subtitle = this.getLobbyTitle(lobby);
+ const mainTitle = translateText("mode_selector.special_title");
+ const titleContent = subtitle
+ ? html`
+
${mainTitle}
+
+ ${subtitle}
+
+ `
+ : mainTitle;
+ return this.renderLobbyCard(lobby, titleContent);
+ }
+
+ private renderQuickActionsSection() {
+ return html`
+
+ ${this.renderSmallActionCard(
+ translateText("main.solo"),
+ this.openSinglePlayerModal,
+ )}
+ ${this.renderSmallActionCard(
+ translateText("mode_selector.ranked_title"),
+ this.openRankedMenu,
+ )}
+ ${this.renderSmallActionCard(
+ translateText("main.create"),
+ this.openHostLobby,
+ )}
+ ${this.renderSmallActionCard(
+ translateText("main.join"),
+ this.openJoinLobby,
+ )}
+
+ `;
+ }
+
+ private openRankedMenu = () => {
+ if (!this.validateUsername()) return;
+ window.showPage?.("page-ranked");
+ };
+
+ private openSinglePlayerModal = () => {
+ if (!this.validateUsername()) return;
+ (
+ document.querySelector("single-player-modal") as SinglePlayerModal
+ )?.open();
+ };
+
+ private openHostLobby = () => {
+ if (!this.validateUsername()) return;
+ (document.querySelector("host-lobby-modal") as HostLobbyModal)?.open();
+ };
+
+ private openJoinLobby = () => {
+ if (!this.validateUsername()) return;
+ (document.querySelector("join-lobby-modal") as JoinLobbyModal)?.open();
+ };
+
+ private renderSmallActionCard(title: string, onClick: () => void) {
+ return html`
+
+ `;
+ }
+
+ private renderLobbyCard(
+ lobby: PublicGameInfo,
+ titleContent: string | TemplateResult,
+ ) {
+ const mapType = lobby.gameConfig!.gameMap as GameMapType;
+ const mapImageSrc = terrainMapFileLoader.getMapData(mapType).webpPath;
+ const timeRemaining = Math.max(
+ 0,
+ Math.floor((lobby.startsAt - this.serverTimeOffset - Date.now()) / 1000),
+ );
+ const timeDisplay = renderDuration(timeRemaining);
+ const mapName = getMapName(lobby.gameConfig?.gameMap);
+
+ const modifierLabels = this.getModifierLabels(
+ lobby.gameConfig?.publicGameModifiers,
+ );
+ // Sort by length for visual consistency (shorter labels first)
+ if (modifierLabels.length > 1) {
+ modifierLabels.sort((a, b) => a.length - b.length);
+ }
+
+ return html`
+
+ `;
+ }
+
+ private validateAndJoin(lobby: PublicGameInfo) {
+ if (!this.validateUsername()) return;
+
+ this.dispatchEvent(
+ new CustomEvent("join-lobby", {
+ detail: {
+ gameID: lobby.gameID,
+ source: "public",
+ publicLobbyInfo: lobby,
+ } as JoinLobbyEvent,
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ }
+
+ private getModifierLabels(mods: PublicGameModifiers | undefined): string[] {
+ if (!mods) return [];
+ return [
+ mods.isRandomSpawn && translateText("public_game_modifier.random_spawn"),
+ mods.isCompact && translateText("public_game_modifier.compact_map"),
+ mods.isCrowded && translateText("public_game_modifier.crowded"),
+ mods.startingGold && translateText("public_game_modifier.starting_gold"),
+ ].filter((x): x is string => !!x);
+ }
+
+ private getLobbyTitle(lobby: PublicGameInfo): string {
+ const config = lobby.gameConfig!;
+ if (config.gameMode === GameMode.FFA) {
+ return translateText("game_mode.ffa");
+ }
+
+ if (config?.gameMode === GameMode.Team) {
+ const totalPlayers = config.maxPlayers ?? lobby.numClients ?? undefined;
+ const formatTeamsOf = (
+ teamCount: number | undefined,
+ playersPerTeam: number | undefined,
+ label?: string,
+ ) => {
+ if (!teamCount)
+ return label ?? translateText("mode_selector.teams_title");
+ const baseTitle = playersPerTeam
+ ? translateText("mode_selector.teams_of", {
+ teamCount: String(teamCount),
+ playersPerTeam: String(playersPerTeam),
+ })
+ : translateText("mode_selector.teams_count", {
+ teamCount: String(teamCount),
+ });
+ return `${baseTitle}${label ? ` (${label})` : ""}`;
+ };
+
+ switch (config.playerTeams) {
+ case Duos: {
+ const teamCount = totalPlayers
+ ? Math.floor(totalPlayers / 2)
+ : undefined;
+ return teamCount
+ ? translateText("public_lobby.teams_Duos", {
+ team_count: String(teamCount),
+ })
+ : formatTeamsOf(undefined, 2);
+ }
+ case Trios: {
+ const teamCount = totalPlayers
+ ? Math.floor(totalPlayers / 3)
+ : undefined;
+ return teamCount
+ ? translateText("public_lobby.teams_Trios", {
+ team_count: String(teamCount),
+ })
+ : formatTeamsOf(undefined, 3);
+ }
+ case Quads: {
+ const teamCount = totalPlayers
+ ? Math.floor(totalPlayers / 4)
+ : undefined;
+ return teamCount
+ ? translateText("public_lobby.teams_Quads", {
+ team_count: String(teamCount),
+ })
+ : formatTeamsOf(undefined, 4);
+ }
+ case HumansVsNations: {
+ const humanSlots = config.maxPlayers ?? lobby.numClients;
+ return humanSlots
+ ? translateText("public_lobby.teams_hvn_detailed", {
+ num: String(humanSlots),
+ })
+ : translateText("public_lobby.teams_hvn");
+ }
+ default:
+ if (typeof config.playerTeams === "number") {
+ const teamCount = config.playerTeams;
+ const playersPerTeam =
+ totalPlayers && teamCount > 0
+ ? Math.floor(totalPlayers / teamCount)
+ : undefined;
+ return formatTeamsOf(teamCount, playersPerTeam);
+ }
+ }
+ }
+
+ return "";
+ }
+}
diff --git a/src/client/HelpModal.ts b/src/client/HelpModal.ts
index fde1e65451..95ba888807 100644
--- a/src/client/HelpModal.ts
+++ b/src/client/HelpModal.ts
@@ -99,14 +99,10 @@ export class HelpModal extends BaseModal {
const keybinds = this.keybinds;
const content = html`
-
+
${modalHeader({
title: translateText("main.help"),
- onBack: this.close,
+ onBack: () => this.close(),
ariaLabel: translateText("common.back"),
})}
diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts
index 71e661842e..978fa12ac5 100644
--- a/src/client/HostLobbyModal.ts
+++ b/src/client/HostLobbyModal.ts
@@ -78,8 +78,6 @@ export class HostLobbyModal extends BaseModal {
@state() private nationCount: number = 0;
@property({ attribute: false }) eventBus: EventBus | null = null;
-
- private playersInterval: NodeJS.Timeout | null = null;
// Add a new timer for debouncing bot changes
private botsUpdateTimer: number | null = null;
private mapLoader = terrainMapFileLoader;
@@ -88,6 +86,9 @@ export class HostLobbyModal extends BaseModal {
private readonly handleLobbyInfo = (event: LobbyInfoEvent) => {
const lobby = event.lobby;
+ if (!this.lobbyId || lobby.gameID !== this.lobbyId) {
+ return;
+ }
this.lobbyCreatorClientID = lobby.lobbyCreatorClientID ?? "";
if (lobby.clients) {
this.clients = lobby.clients;
@@ -209,9 +210,7 @@ export class HostLobbyModal extends BaseModal {
];
const content = html`
-
+
${modalHeader({
title: translateText("host_modal.title"),
@@ -391,7 +390,6 @@ export class HostLobbyModal extends BaseModal {
this.close();
};
}
- this.playersInterval = setInterval(() => this.pollPlayers(), 1000);
this.loadNationCount();
}
@@ -418,10 +416,6 @@ export class HostLobbyModal extends BaseModal {
crazyGamesSDK.hideInviteButton();
// Clean up timers and resources
- if (this.playersInterval) {
- clearInterval(this.playersInterval);
- this.playersInterval = null;
- }
if (this.botsUpdateTimer !== null) {
clearTimeout(this.botsUpdateTimer);
this.botsUpdateTimer = null;
@@ -811,20 +805,6 @@ export class HostLobbyModal extends BaseModal {
return response;
}
- private async pollPlayers() {
- const config = await getServerConfigFromClient();
- fetch(`/${config.workerPath(this.lobbyId)}/api/game/${this.lobbyId}`, {
- method: "GET",
- headers: {
- "Content-Type": "application/json",
- },
- })
- .then((response) => response.json())
- .then((data: GameInfo) => {
- this.clients = data.clients ?? [];
- });
- }
-
private kickPlayer(clientID: string) {
// Dispatch event to be handled by WebSocket instead of HTTP
this.dispatchEvent(
diff --git a/src/client/JoinLobbyModal.ts b/src/client/JoinLobbyModal.ts
index cb0aafebd9..22e674fdab 100644
--- a/src/client/JoinLobbyModal.ts
+++ b/src/client/JoinLobbyModal.ts
@@ -3,7 +3,7 @@ import { customElement, property, query, state } from "lit/decorators.js";
import {
getActiveModifiers,
getGameModeLabel,
- normaliseMapKey,
+ getMapName,
renderDuration,
renderNumber,
translateText,
@@ -16,6 +16,7 @@ import {
GameInfo,
GameRecordSchema,
LobbyInfoEvent,
+ PublicGameInfo,
} from "../core/Schemas";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import {
@@ -96,9 +97,7 @@ export class JoinLobbyModal extends BaseModal {
? (this.lobbyCreatorClientID ?? "")
: "";
const content = html`
-
+
${modalHeader({
title: translateText("public_lobby.title"),
onBack: () => this.closeAndLeave(),
@@ -149,7 +148,7 @@ export class JoinLobbyModal extends BaseModal {
${this.isPrivateLobby()
? html`