diff --git a/resources/lang/en.json b/resources/lang/en.json
index df9ec4f66..eeb64dc1c 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -753,6 +753,7 @@
"host_modal": {
"anonymous_players": "Anonymous players",
"assigned_teams": "Assigned Teams",
+ "auto_start_timer": "Time until the game starts automatically",
"bots": "Tribes: ",
"bots_disabled": "Disabled",
"compact_map": "Compact Map",
diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts
index d34317c51..d9cf6930a 100644
--- a/src/client/HostLobbyModal.ts
+++ b/src/client/HostLobbyModal.ts
@@ -1,4 +1,4 @@
-import { html } from "lit";
+import { html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ClientEnv } from "src/client/ClientEnv";
import {
@@ -112,6 +112,8 @@ export class HostLobbyModal extends BaseModal {
@state() private canListPublicly: boolean = false;
@state() private publiclyListed: boolean = false;
@state() private showSubscriptionRequired: boolean = false;
+ // Server timestamp when the listed lobby auto-starts (from lobby info).
+ @state() private autoStartAt: number | null = null;
@property({ attribute: false }) eventBus: EventBus | null = null;
// Timers for debouncing slider changes
@@ -146,6 +148,7 @@ export class HostLobbyModal extends BaseModal {
if (!this.listingRequestInFlight && lobby.listed !== undefined) {
this.publiclyListed = lobby.listed;
}
+ this.autoStartAt = lobby.autoStartAt ?? null;
};
private getRandomString(): string {
@@ -248,9 +251,32 @@ export class HostLobbyModal extends BaseModal {
${segment("host_modal.visibility_private", false)}
${segment("host_modal.visibility_public", true)}
+ ${this.renderAutoStartTimer()}
`;
}
+ // Countdown until the listed lobby starts automatically (hosts can't sit
+ // on a public listing). Hidden once the real start countdown is running —
+ // the Start button shows that one.
+ private renderAutoStartTimer() {
+ if (
+ !this.publiclyListed ||
+ this.autoStartAt === null ||
+ this.lobbyStartAt !== null
+ ) {
+ return nothing;
+ }
+ const seconds = getSecondsUntilServerTimestamp(
+ this.autoStartAt,
+ this.serverTimeOffset,
+ );
+ return html`${renderDuration(seconds)}`;
+ }
+
private handleVisibilitySelect(isPublic: boolean) {
if (
this.listingRequestInFlight ||
@@ -738,6 +764,7 @@ export class HostLobbyModal extends BaseModal {
this.hostCheatStartingGoldValue = undefined;
this.publiclyListed = false;
this.showSubscriptionRequired = false;
+ this.autoStartAt = null;
this.leaveLobbyOnClose = true;
}
diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts
index 1e3cd9d2f..425f8d994 100644
--- a/src/core/Schemas.ts
+++ b/src/core/Schemas.ts
@@ -176,6 +176,11 @@ export type ScheduledPublicGameType = z.infer<
// and delists any overflow as the authoritative backstop.
export const MAX_HOSTED_LOBBIES = 10;
+// How long a lobby may stay publicly listed before it starts automatically,
+// so hosts can't sit on a listing indefinitely. Unlisting cancels the
+// deadline; relisting starts a fresh one.
+export const HOSTED_LOBBY_AUTO_START_MS = 5 * 60 * 1000;
+
export const UsernameSchema = z
.string()
.regex(/^(?=.*\S)[a-zA-Z0-9_ üÜ.]+$/u)
@@ -207,6 +212,9 @@ export const GameInfoSchema = z.object({
// UI stays in sync when the server delists (whitelist enabled, duplicate
// creator resolved by the master).
listed: z.boolean().optional(),
+ // Listed lobbies only: server timestamp when the lobby starts
+ // automatically (hosts can't sit on a public listing indefinitely).
+ autoStartAt: z.number().optional(),
});
// Browser-facing lobby info. Master/worker-internal fields (the creator hash
diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts
index 93a137b8a..eba8a711e 100644
--- a/src/server/GameManager.ts
+++ b/src/server/GameManager.ts
@@ -126,6 +126,9 @@ export class GameManager {
const active = new Map();
for (const [id, game] of this.games) {
const phase = game.phase();
+ if (phase === GamePhase.Lobby) {
+ game.maybeAutoStartListed();
+ }
if (phase === GamePhase.Active) {
if (!game.hasStarted()) {
// Prestart tells clients to start loading the game.
diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts
index 2cfea3d07..0afb2f5f3 100644
--- a/src/server/GameServer.ts
+++ b/src/server/GameServer.ts
@@ -15,6 +15,7 @@ import {
GameInfo,
GameStartInfo,
GameStartInfoSchema,
+ HOSTED_LOBBY_AUTO_START_MS,
Intent,
LiveStats,
PlayerLiveStats,
@@ -149,6 +150,9 @@ export class GameServer {
// only the authenticated /api/game/:id/listing endpoint may (it verifies
// the creator's subscription).
private listed = false;
+ // When the lobby was listed; drives the auto-start deadline. Cleared on
+ // delist, so relisting starts a fresh deadline.
+ private listedAt?: number;
private lobbyInfoIntervalId: ReturnType | null = null;
@@ -274,7 +278,7 @@ export class GameServer {
// A join whitelist and public listing are mutually exclusive: a listed
// lobby must be joinable by anyone who finds it in the lobby browser.
if (this.listed && this.hasJoinWhitelist()) {
- this.listed = false;
+ this.setListed(false);
this.log.info("delisted lobby: join whitelist enabled");
}
}
@@ -1154,6 +1158,7 @@ export class GameServer {
serverTime: Date.now(),
publicGameType: this.publicGameType,
listed: this.isPublic() ? undefined : this.listed,
+ autoStartAt: this.autoStartAt(),
};
}
@@ -1183,7 +1188,38 @@ export class GameServer {
}
public setListed(listed: boolean): void {
+ if (this.listed === listed) {
+ // Duplicate toggles must not extend the auto-start deadline.
+ return;
+ }
this.listed = listed;
+ this.listedAt = listed ? Date.now() : undefined;
+ }
+
+ // Deadline after which a listed lobby starts automatically, so hosts
+ // can't sit on a public listing indefinitely.
+ public autoStartAt(): number | undefined {
+ return this.listed && this.listedAt !== undefined
+ ? this.listedAt + HOSTED_LOBBY_AUTO_START_MS
+ : undefined;
+ }
+
+ // Called from GameManager's tick while in the Lobby phase: once the
+ // listed deadline passes, arm the normal start countdown (same path as
+ // the host's Start button). Cancelling the countdown re-arms it on the
+ // next tick, so the only way out is to unlist.
+ public maybeAutoStartListed(): void {
+ if (this.hasStarted() || this.startsAt !== undefined) {
+ return;
+ }
+ const deadline = this.autoStartAt();
+ if (deadline === undefined || Date.now() < deadline) {
+ return;
+ }
+ this.log.info("listed lobby reached auto-start deadline, starting", {
+ gameID: this.id,
+ });
+ this.setStartsAt(Date.now() + (this.gameConfig.startDelay ?? 0) * 1000);
}
// Whether joining is restricted to an allowlist of publicIds. A lobby with
diff --git a/tests/server/HostedLobbyListing.test.ts b/tests/server/HostedLobbyListing.test.ts
index c388dcb00..b052308c8 100644
--- a/tests/server/HostedLobbyListing.test.ts
+++ b/tests/server/HostedLobbyListing.test.ts
@@ -8,7 +8,10 @@ import {
GameMode,
GameType,
} from "../../src/core/game/Game";
-import { MAX_HOSTED_LOBBIES } from "../../src/core/Schemas";
+import {
+ HOSTED_LOBBY_AUTO_START_MS,
+ MAX_HOSTED_LOBBIES,
+} from "../../src/core/Schemas";
import { Client } from "../../src/server/Client";
import { GameManager } from "../../src/server/GameManager";
import {
@@ -188,6 +191,68 @@ describe("GameManager.listedLobbies", () => {
});
});
+describe("listed lobby auto-start", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.clearAllTimers();
+ vi.useRealTimers();
+ });
+
+ it("tracks the deadline from when the lobby was listed", () => {
+ const game = makeGame();
+ expect(game.autoStartAt()).toBeUndefined();
+
+ game.setListed(true);
+ const deadline = Date.now() + HOSTED_LOBBY_AUTO_START_MS;
+ expect(game.autoStartAt()).toBe(deadline);
+ expect(game.gameInfo().autoStartAt).toBe(deadline);
+
+ // A duplicate toggle must not extend the deadline...
+ vi.setSystemTime(Date.now() + 60_000);
+ game.setListed(true);
+ expect(game.autoStartAt()).toBe(deadline);
+
+ // ...unlisting cancels it, and relisting starts a fresh one.
+ game.setListed(false);
+ expect(game.autoStartAt()).toBeUndefined();
+ game.setListed(true);
+ expect(game.autoStartAt()).toBe(Date.now() + HOSTED_LOBBY_AUTO_START_MS);
+ });
+
+ it("arms the start countdown only once the deadline passes", () => {
+ const gm = new GameManager(mockLogger);
+ const game = gm.createGame(
+ "g-auto-start",
+ { startDelay: 30 } as any,
+ CREATOR,
+ )!;
+ game.setListed(true);
+
+ vi.setSystemTime(Date.now() + HOSTED_LOBBY_AUTO_START_MS - 1000);
+ gm.tick();
+ expect(game.gameInfo().startsAt).toBeUndefined();
+
+ vi.setSystemTime(Date.now() + 2000);
+ gm.tick();
+ expect(game.gameInfo().startsAt).toBe(Date.now() + 30_000);
+ // Still listed while the start countdown runs (the phase change on
+ // start delists).
+ expect(gm.listedLobbies()).toHaveLength(1);
+ });
+
+ it("never auto-starts an unlisted lobby", () => {
+ const gm = new GameManager(mockLogger);
+ const game = gm.createGame("g-manual", undefined, CREATOR)!;
+
+ vi.setSystemTime(Date.now() + HOSTED_LOBBY_AUTO_START_MS * 2);
+ gm.tick();
+ expect(game.gameInfo().startsAt).toBeUndefined();
+ });
+});
+
function fakeWs() {
const ws = new EventEmitter() as any;
ws.readyState = WebSocket.OPEN;