diff --git a/resources/lang/en.json b/resources/lang/en.json
index e45598c076..66bbda6ed2 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -744,6 +744,10 @@
"choose_spawn": "Choose a starting location",
"random_spawn": "Random spawn is enabled. Selecting starting location for you..."
},
+ "pause": {
+ "singleplayer_game_paused": "Game paused",
+ "multiplayer_game_paused": "Game paused by Lobby Creator"
+ },
"territory_patterns": {
"title": "Skins",
"colors": "Colors",
diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts
index 5089b6be22..27c2f01cbd 100644
--- a/src/client/ClientGameRunner.ts
+++ b/src/client/ClientGameRunner.ts
@@ -328,6 +328,7 @@ export class ClientGameRunner {
this.saveGame(gu.updates[GameUpdateType.Win][0]);
}
});
+
const worker = this.worker;
const keepWorkerAlive = () => {
if (this.isActive) {
@@ -432,7 +433,17 @@ export class ClientGameRunner {
`got wrong turn have turns ${this.turnsSeen}, received turn ${message.turn.turnNumber}`,
);
} else {
- this.worker.sendTurn(message.turn);
+ this.worker.sendTurn(
+ // Filter out pause intents in replays
+ this.gameView.config().isReplay()
+ ? {
+ ...message.turn,
+ intents: message.turn.intents.filter(
+ (i) => i.type !== "toggle_pause",
+ ),
+ }
+ : message.turn,
+ );
this.turnsSeen++;
}
}
diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts
index cba4287393..100bbc3f98 100644
--- a/src/client/LocalServer.ts
+++ b/src/client/LocalServer.ts
@@ -97,14 +97,6 @@ export class LocalServer {
} satisfies ServerStartGameMessage);
}
- pause() {
- this.paused = true;
- }
-
- resume() {
- this.paused = false;
- }
-
onMessage(clientMsg: ClientMessage) {
if (clientMsg.type === "rejoin") {
this.clientMessage({
@@ -115,13 +107,25 @@ export class LocalServer {
} satisfies ServerStartGameMessage);
}
if (clientMsg.type === "intent") {
- if (this.lobbyConfig.gameRecord) {
- // If we are replaying a game, we don't want to process intents
+ if (clientMsg.intent.type === "toggle_pause") {
+ if (clientMsg.intent.paused) {
+ // Pausing: add intent and end turn before pause takes effect
+ this.intents.push(clientMsg.intent);
+ this.endTurn();
+ this.paused = true;
+ } else {
+ // Unpausing: clear pause flag before adding intent so next turn can execute
+ this.paused = false;
+ this.intents.push(clientMsg.intent);
+ this.endTurn();
+ }
return;
}
- if (this.paused) {
+ // Don't process non-pause intents during replays or while paused
+ if (this.lobbyConfig.gameRecord || this.paused) {
return;
}
+
this.intents.push(clientMsg.intent);
}
if (clientMsg.type === "hash") {
diff --git a/src/client/Transport.ts b/src/client/Transport.ts
index 8c94d215f7..15bba631ec 100644
--- a/src/client/Transport.ts
+++ b/src/client/Transport.ts
@@ -29,7 +29,7 @@ import { getPlayToken } from "./Auth";
import { LobbyConfig } from "./ClientGameRunner";
import { LocalServer } from "./LocalServer";
-export class PauseGameEvent implements GameEvent {
+export class PauseGameIntentEvent implements GameEvent {
constructor(public readonly paused: boolean) {}
}
@@ -186,6 +186,7 @@ export class Transport {
private pingInterval: number | null = null;
public readonly isLocal: boolean;
+
constructor(
private lobbyConfig: LobbyConfig,
private eventBus: EventBus,
@@ -237,7 +238,7 @@ export class Transport {
);
this.eventBus.on(BuildUnitIntentEvent, (e) => this.onBuildUnitIntent(e));
- this.eventBus.on(PauseGameEvent, (e) => this.onPauseGameEvent(e));
+ this.eventBus.on(PauseGameIntentEvent, (e) => this.onPauseGameIntent(e));
this.eventBus.on(SendWinnerEvent, (e) => this.onSendWinnerEvent(e));
this.eventBus.on(SendHashEvent, (e) => this.onSendHashEvent(e));
this.eventBus.on(CancelAttackIntentEvent, (e) =>
@@ -575,16 +576,12 @@ export class Transport {
});
}
- private onPauseGameEvent(event: PauseGameEvent) {
- if (!this.isLocal) {
- console.log(`cannot pause multiplayer games`);
- return;
- }
- if (event.paused) {
- this.localServer.pause();
- } else {
- this.localServer.resume();
- }
+ private onPauseGameIntent(event: PauseGameIntentEvent) {
+ this.sendIntent({
+ type: "toggle_pause",
+ clientID: this.lobbyConfig.clientID,
+ paused: event.paused,
+ });
}
private onSendWinnerEvent(event: SendWinnerEvent) {
diff --git a/src/client/graphics/layers/GameRightSidebar.ts b/src/client/graphics/layers/GameRightSidebar.ts
index d73c473600..cc0f777e59 100644
--- a/src/client/graphics/layers/GameRightSidebar.ts
+++ b/src/client/graphics/layers/GameRightSidebar.ts
@@ -10,7 +10,7 @@ import { GameType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { crazyGamesSDK } from "../../CrazyGamesSDK";
-import { PauseGameEvent } from "../../Transport";
+import { PauseGameIntentEvent } from "../../Transport";
import { translateText } from "../../Utils";
import { Layer } from "./Layer";
import { ShowReplayPanelEvent } from "./ReplayPanel";
@@ -37,6 +37,7 @@ export class GameRightSidebar extends LitElement implements Layer {
private timer: number = 0;
private hasWinner = false;
+ private isLobbyCreator = false;
createRenderRoot() {
return this;
@@ -48,6 +49,7 @@ export class GameRightSidebar extends LitElement implements Layer {
this.game.config().isReplay();
this._isVisible = true;
this.game.inSpawnPhase();
+
this.requestUpdate();
}
@@ -57,6 +59,13 @@ export class GameRightSidebar extends LitElement implements Layer {
if (updates) {
this.hasWinner = this.hasWinner || updates[GameUpdateType.Win].length > 0;
}
+
+ // Check if the player is the lobby creator
+ if (!this.isLobbyCreator && this.game.myPlayer()?.isLobbyCreator()) {
+ this.isLobbyCreator = true;
+ this.requestUpdate();
+ }
+
const maxTimerValue = this.game.config().gameConfig().maxTimerValue;
if (maxTimerValue !== undefined) {
if (this.game.inSpawnPhase()) {
@@ -96,7 +105,7 @@ export class GameRightSidebar extends LitElement implements Layer {
private onPauseButtonClick() {
this.isPaused = !this.isPaused;
- this.eventBus.emit(new PauseGameEvent(this.isPaused));
+ this.eventBus.emit(new PauseGameIntentEvent(this.isPaused));
}
private onExitButtonClick() {
@@ -153,25 +162,35 @@ export class GameRightSidebar extends LitElement implements Layer {
}
maybeRenderReplayButtons() {
- if (this._isSinglePlayer || this.game?.config()?.isReplay()) {
- return html`
-

-
-
-

-
`;
- } else {
- return html``;
- }
+ const isReplayOrSingleplayer =
+ this._isSinglePlayer || this.game?.config()?.isReplay();
+ const showPauseButton = isReplayOrSingleplayer || this.isLobbyCreator;
+
+ return html`
+ ${isReplayOrSingleplayer
+ ? html`
+
+

+
+ `
+ : ""}
+ ${showPauseButton
+ ? html`
+
+

+
+ `
+ : ""}
+ `;
}
}
diff --git a/src/client/graphics/layers/HeadsUpMessage.ts b/src/client/graphics/layers/HeadsUpMessage.ts
index 9ab20dae35..d9c224bea7 100644
--- a/src/client/graphics/layers/HeadsUpMessage.ts
+++ b/src/client/graphics/layers/HeadsUpMessage.ts
@@ -1,5 +1,7 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
+import { GameType } from "../../../core/game/Game";
+import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { translateText } from "../../Utils";
import { Layer } from "./Layer";
@@ -11,6 +13,9 @@ export class HeadsUpMessage extends LitElement implements Layer {
@state()
private isVisible = false;
+ @state()
+ private isPaused = false;
+
createRenderRoot() {
return this;
}
@@ -21,10 +26,27 @@ export class HeadsUpMessage extends LitElement implements Layer {
}
tick() {
- if (!this.game.inSpawnPhase()) {
- this.isVisible = false;
- this.requestUpdate();
+ const updates = this.game.updatesSinceLastTick();
+ if (updates && updates[GameUpdateType.GamePaused].length > 0) {
+ const pauseUpdate = updates[GameUpdateType.GamePaused][0];
+ this.isPaused = pauseUpdate.paused;
+ }
+
+ this.isVisible = this.game.inSpawnPhase() || this.isPaused;
+ this.requestUpdate();
+ }
+
+ private getMessage(): string {
+ if (this.isPaused) {
+ if (this.game.config().gameConfig().gameType === GameType.Singleplayer) {
+ return translateText("pause.singleplayer_game_paused");
+ } else {
+ return translateText("pause.multiplayer_game_paused");
+ }
}
+ return this.game.config().isRandomSpawn()
+ ? translateText("heads_up_message.random_spawn")
+ : translateText("heads_up_message.choose_spawn");
}
render() {
@@ -32,17 +54,17 @@ export class HeadsUpMessage extends LitElement implements Layer {
return html``;
}
+ const message = this.getMessage();
+
return html`
e.preventDefault()}
>
- ${this.game.config().isRandomSpawn()
- ? translateText("heads_up_message.random_spawn")
- : translateText("heads_up_message.choose_spawn")}
+ ${message}
`;
}
diff --git a/src/client/graphics/layers/SettingsModal.ts b/src/client/graphics/layers/SettingsModal.ts
index f2fecc5ffa..742caa298b 100644
--- a/src/client/graphics/layers/SettingsModal.ts
+++ b/src/client/graphics/layers/SettingsModal.ts
@@ -15,7 +15,7 @@ import musicIcon from "../../../../resources/images/music.svg";
import { EventBus } from "../../../core/EventBus";
import { UserSettings } from "../../../core/game/UserSettings";
import { AlternateViewEvent, RefreshGraphicsEvent } from "../../InputHandler";
-import { PauseGameEvent } from "../../Transport";
+import { PauseGameIntentEvent } from "../../Transport";
import { translateText } from "../../Utils";
import SoundManager from "../../sound/SoundManager";
import { Layer } from "./Layer";
@@ -108,7 +108,7 @@ export class SettingsModal extends LitElement implements Layer {
private pauseGame(pause: boolean) {
if (this.shouldPause && !this.wasPausedWhenOpened)
- this.eventBus.emit(new PauseGameEvent(pause));
+ this.eventBus.emit(new PauseGameIntentEvent(pause));
}
private onTerrainButtonClick() {
diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts
index ed8c8cd7b2..92eba215b7 100644
--- a/src/core/GameRunner.ts
+++ b/src/core/GameRunner.ts
@@ -52,6 +52,7 @@ export async function createGameRunner(
PlayerType.Human,
p.clientID,
random.nextID(),
+ p.isLobbyCreator ?? false,
);
});
diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts
index 9370d6a477..27ceb7c053 100644
--- a/src/core/Schemas.ts
+++ b/src/core/Schemas.ts
@@ -47,7 +47,8 @@ export type Intent =
| EmbargoAllIntent
| UpgradeStructureIntent
| DeleteUnitIntent
- | KickPlayerIntent;
+ | KickPlayerIntent
+ | TogglePauseIntent;
export type AttackIntent = z.infer;
export type CancelAttackIntent = z.infer;
@@ -79,6 +80,7 @@ export type AllianceExtensionIntent = z.infer<
>;
export type DeleteUnitIntent = z.infer;
export type KickPlayerIntent = z.infer;
+export type TogglePauseIntent = z.infer;
export type Turn = z.infer;
export type GameConfig = z.infer;
@@ -91,6 +93,7 @@ export type ClientMessage =
| ClientRejoinMessage
| ClientLogMessage
| ClientHashMessage;
+
export type ServerMessage =
| ServerTurnMessage
| ServerStartGameMessage
@@ -354,6 +357,11 @@ export const KickPlayerIntentSchema = BaseIntentSchema.extend({
target: ID,
});
+export const TogglePauseIntentSchema = BaseIntentSchema.extend({
+ type: z.literal("toggle_pause"),
+ paused: z.boolean().default(false),
+});
+
const IntentSchema = z.discriminatedUnion("type", [
AttackIntentSchema,
CancelAttackIntentSchema,
@@ -377,6 +385,7 @@ const IntentSchema = z.discriminatedUnion("type", [
AllianceExtensionIntentSchema,
DeleteUnitIntentSchema,
KickPlayerIntentSchema,
+ TogglePauseIntentSchema,
]);
//
@@ -430,6 +439,7 @@ export const PlayerSchema = z.object({
clientID: ID,
username: UsernameSchema,
cosmetics: PlayerCosmeticsSchema.optional(),
+ isLobbyCreator: z.boolean().optional(),
});
export const GameStartInfoSchema = z.object({
diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts
index 5e39140119..e0625fddce 100644
--- a/src/core/execution/ExecutionManager.ts
+++ b/src/core/execution/ExecutionManager.ts
@@ -20,6 +20,7 @@ import { MarkDisconnectedExecution } from "./MarkDisconnectedExecution";
import { MoveWarshipExecution } from "./MoveWarshipExecution";
import { NationExecution } from "./NationExecution";
import { NoOpExecution } from "./NoOpExecution";
+import { PauseExecution } from "./PauseExecution";
import { QuickChatExecution } from "./QuickChatExecution";
import { RetreatExecution } from "./RetreatExecution";
import { SpawnExecution } from "./SpawnExecution";
@@ -123,6 +124,8 @@ export class Executor {
);
case "mark_disconnected":
return new MarkDisconnectedExecution(player, intent.isDisconnected);
+ case "toggle_pause":
+ return new PauseExecution(player, intent.paused);
default:
throw new Error(`intent type ${intent} not found`);
}
diff --git a/src/core/execution/PauseExecution.ts b/src/core/execution/PauseExecution.ts
new file mode 100644
index 0000000000..4cc20eb634
--- /dev/null
+++ b/src/core/execution/PauseExecution.ts
@@ -0,0 +1,27 @@
+import { Execution, Game, GameType, Player } from "../game/Game";
+
+export class PauseExecution implements Execution {
+ constructor(
+ private player: Player,
+ private paused: boolean,
+ ) {}
+
+ isActive(): boolean {
+ return false;
+ }
+
+ activeDuringSpawnPhase(): boolean {
+ return true;
+ }
+
+ init(game: Game, ticks: number): void {
+ if (
+ this.player.isLobbyCreator() ||
+ game.config().gameConfig().gameType === GameType.Singleplayer
+ ) {
+ game.setPaused(this.paused);
+ }
+ }
+
+ tick(ticks: number): void {}
+}
diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts
index 9c5ef95ffd..1dff9aee39 100644
--- a/src/core/game/Game.ts
+++ b/src/core/game/Game.ts
@@ -418,6 +418,7 @@ export class PlayerInfo {
public readonly clientID: ClientID | null,
// TODO: make player id the small id
public readonly id: PlayerID,
+ public readonly isLobbyCreator: boolean = false,
) {
this.clan = getClanTag(name);
}
@@ -538,6 +539,7 @@ export interface Player {
type(): PlayerType;
isPlayer(): this is Player;
toString(): string;
+ isLobbyCreator(): boolean;
// State & Properties
isAlive(): boolean;
@@ -707,6 +709,8 @@ export interface Game extends GameMap {
executeNextTick(): GameUpdates;
setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void;
config(): Config;
+ isPaused(): boolean;
+ setPaused(paused: boolean): void;
// Units
units(...types: UnitType[]): Unit[];
diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts
index 61a6e38d72..9100767092 100644
--- a/src/core/game/GameImpl.ts
+++ b/src/core/game/GameImpl.ts
@@ -85,6 +85,8 @@ export class GameImpl implements Game {
// Used to assign unique IDs to each new alliance
private nextAllianceID: number = 0;
+ private _isPaused: boolean = false;
+
constructor(
private _humans: PlayerInfo[],
private _nations: Nation[],
@@ -337,6 +339,15 @@ export class GameImpl implements Game {
return this._config;
}
+ isPaused(): boolean {
+ return this._isPaused;
+ }
+
+ setPaused(paused: boolean): void {
+ this._isPaused = paused;
+ this.addUpdate({ type: GameUpdateType.GamePaused, paused });
+ }
+
inSpawnPhase(): boolean {
return this._ticks <= this.config().numSpawnPhaseTurns();
}
diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts
index c558a33911..6cba024bcf 100644
--- a/src/core/game/GameUpdates.ts
+++ b/src/core/game/GameUpdates.ts
@@ -47,6 +47,7 @@ export enum GameUpdateType {
RailroadEvent,
ConquestEvent,
EmbargoEvent,
+ GamePaused,
}
export type GameUpdate =
@@ -68,7 +69,8 @@ export type GameUpdate =
| BonusEventUpdate
| RailroadUpdate
| ConquestUpdate
- | EmbargoUpdate;
+ | EmbargoUpdate
+ | GamePausedUpdate;
export interface BonusEventUpdate {
type: GameUpdateType.BonusEvent;
@@ -172,6 +174,7 @@ export interface PlayerUpdate {
hasSpawned: boolean;
betrayals: number;
lastDeleteUnitTick: Tick;
+ isLobbyCreator: boolean;
}
export interface AllianceView {
@@ -269,3 +272,8 @@ export interface EmbargoUpdate {
playerID: number;
embargoedID: number;
}
+
+export interface GamePausedUpdate {
+ type: GameUpdateType.GamePaused;
+ paused: boolean;
+}
diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts
index a35416364e..2db92f91f5 100644
--- a/src/core/game/GameView.ts
+++ b/src/core/game/GameView.ts
@@ -505,6 +505,10 @@ export class PlayerView {
return this.smallID() === this.game.myPlayer()?.smallID();
}
+ isLobbyCreator(): boolean {
+ return this.data.isLobbyCreator;
+ }
+
isAlliedWith(other: PlayerView): boolean {
return this.data.allies.some((n) => other.smallID() === n);
}
diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts
index 9dab88905d..e771012eca 100644
--- a/src/core/game/PlayerImpl.ts
+++ b/src/core/game/PlayerImpl.ts
@@ -179,6 +179,7 @@ export class PlayerImpl implements Player {
hasSpawned: this.hasSpawned(),
betrayals: this._betrayalCount,
lastDeleteUnitTick: this.lastDeleteUnitTick,
+ isLobbyCreator: this.isLobbyCreator(),
};
}
@@ -338,6 +339,11 @@ export class PlayerImpl implements Player {
info(): PlayerInfo {
return this.playerInfo;
}
+
+ isLobbyCreator(): boolean {
+ return this.playerInfo.isLobbyCreator;
+ }
+
isAlive(): boolean {
return this._tiles.size > 0;
}
diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts
index 0f26df3688..428275f3c0 100644
--- a/src/server/GameServer.ts
+++ b/src/server/GameServer.ts
@@ -62,6 +62,8 @@ export class GameServer {
private kickedClients: Set = new Set();
private outOfSyncClients: Set = new Set();
+ private isPaused = false;
+
private websockets: Set = new Set();
private winnerVotes: Map<
@@ -346,8 +348,40 @@ export class GameServer {
this.kickClient(clientMsg.intent.target);
return;
}
+ case "toggle_pause": {
+ // Only lobby creator can pause/resume
+ if (client.clientID !== this.lobbyCreatorID) {
+ this.log.warn(`Only lobby creator can toggle pause`, {
+ clientID: client.clientID,
+ creatorID: this.lobbyCreatorID,
+ gameID: this.id,
+ });
+ return;
+ }
+
+ if (clientMsg.intent.paused) {
+ // Pausing: send intent and complete current turn before pause takes effect
+ this.addIntent(clientMsg.intent);
+ this.endTurn();
+ this.isPaused = true;
+ } else {
+ // Unpausing: clear pause flag before sending intent so next turn can execute
+ this.isPaused = false;
+ this.addIntent(clientMsg.intent);
+ this.endTurn();
+ }
+
+ this.log.info(`Game ${this.isPaused ? "paused" : "resumed"}`, {
+ clientID: client.clientID,
+ gameID: this.id,
+ });
+ break;
+ }
default: {
- this.addIntent(clientMsg.intent);
+ // Don't process intents while game is paused
+ if (!this.isPaused) {
+ this.addIntent(clientMsg.intent);
+ }
break;
}
}
@@ -461,6 +495,7 @@ export class GameServer {
username: c.username,
clientID: c.clientID,
cosmetics: c.cosmetics,
+ isLobbyCreator: this.lobbyCreatorID === c.clientID,
})),
});
if (!result.success) {
@@ -488,6 +523,19 @@ export class GameServer {
}
private sendStartGameMsg(ws: WebSocket, lastTurn: number) {
+ // Find which client this websocket belongs to
+ const client = this.activeClients.find((c) => c.ws === ws);
+ if (!client) {
+ this.log.warn("Could not find client for websocket in sendStartGameMsg");
+ return;
+ }
+
+ this.log.info(`Sending start message to client`, {
+ clientID: client.clientID,
+ lobbyCreatorID: this.lobbyCreatorID,
+ isLobbyCreator: this.lobbyCreatorID === client.clientID,
+ });
+
try {
ws.send(
JSON.stringify({
@@ -508,6 +556,11 @@ export class GameServer {
}
private endTurn() {
+ // Skip turn execution if game is paused
+ if (this.isPaused) {
+ return;
+ }
+
const pastTurn: Turn = {
turnNumber: this.turns.length,
intents: this.intents,