Added pause functionality for private multiplayer games (#2657)

If this PR fixes an issue, link it below. If not, delete these two
lines.
Resolves #2491

## Description:
Adds pause/unpause functionality for private multiplayer games. Only the
lobby creator can pause the game, and all players see a pause overlay
when the game is paused.

  **Key features:**
- Lobby creator sees pause/play button in control panel (alongside
existing singleplayer/replay controls)
  - Server validates that only lobby creator can toggle pause
  - All players see "Game paused by Lobby Creator" overlay when paused
  - Game state freezes (no turn execution) while paused
  - Unpause resumes normal gameplay

  **Implementation details:**
- Server-side pause state (`isPaused`) prevents turn execution during
pause
- Each client receives `isLobbyCreator` flag in `GameStartInfo` to
show/hide pause button
- Added `TogglePauseIntent` that broadcasts to all clients via
`NoOpExecution`
  - New `PauseOverlay` component (shows in single player also)

## 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:

furo18

<img width="1459" height="861" alt="Screenshot 2025-12-20 at 15 16 33"
src="https://github.com/user-attachments/assets/f5a3222f-f54b-473c-b0f6-104ce4c1e7a8"
/>
This commit is contained in:
YoussfeCantCode
2025-12-26 19:21:18 +01:00
committed by GitHub
parent 7f3ca3c97e
commit 1c52d20e83
17 changed files with 243 additions and 59 deletions
+4
View File
@@ -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",
+12 -1
View File
@@ -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++;
}
}
+15 -11
View File
@@ -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") {
+9 -12
View File
@@ -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) {
+41 -22
View File
@@ -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` <div class="cursor-pointer" @click=${this.toggleReplayPanel}>
<img
src=${FastForwardIconSolid}
alt="replay"
width="20"
height="20"
/>
</div>
<div class="cursor-pointer" @click=${this.onPauseButtonClick}>
<img
src=${this.isPaused ? playIcon : pauseIcon}
alt="play/pause"
width="20"
height="20"
/>
</div>`;
} else {
return html``;
}
const isReplayOrSingleplayer =
this._isSinglePlayer || this.game?.config()?.isReplay();
const showPauseButton = isReplayOrSingleplayer || this.isLobbyCreator;
return html`
${isReplayOrSingleplayer
? html`
<div class="cursor-pointer" @click=${this.toggleReplayPanel}>
<img
src=${FastForwardIconSolid}
alt="replay"
width="20"
height="20"
/>
</div>
`
: ""}
${showPauseButton
? html`
<div class="cursor-pointer" @click=${this.onPauseButtonClick}>
<img
src=${this.isPaused ? playIcon : pauseIcon}
alt="play/pause"
width="20"
height="20"
/>
</div>
`
: ""}
`;
}
}
+30 -8
View File
@@ -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`
<div
class="flex items-center relative
w-full justify-evenly h-8 lg:h-10 md:top-[70px] left-0 lg:left-4
bg-opacity-60 bg-gray-900 rounded-md lg:rounded-lg
w-full justify-evenly h-8 lg:h-10 md:top-[70px] left-0 lg:left-4
bg-opacity-60 bg-gray-900 rounded-md lg:rounded-lg
backdrop-blur-md text-white text-md lg:text-xl p-1 lg:p-2"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
>
${this.game.config().isRandomSpawn()
? translateText("heads_up_message.random_spawn")
: translateText("heads_up_message.choose_spawn")}
${message}
</div>
`;
}
+2 -2
View File
@@ -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() {
+1
View File
@@ -52,6 +52,7 @@ export async function createGameRunner(
PlayerType.Human,
p.clientID,
random.nextID(),
p.isLobbyCreator ?? false,
);
});
+11 -1
View File
@@ -47,7 +47,8 @@ export type Intent =
| EmbargoAllIntent
| UpgradeStructureIntent
| DeleteUnitIntent
| KickPlayerIntent;
| KickPlayerIntent
| TogglePauseIntent;
export type AttackIntent = z.infer<typeof AttackIntentSchema>;
export type CancelAttackIntent = z.infer<typeof CancelAttackIntentSchema>;
@@ -79,6 +80,7 @@ export type AllianceExtensionIntent = z.infer<
>;
export type DeleteUnitIntent = z.infer<typeof DeleteUnitIntentSchema>;
export type KickPlayerIntent = z.infer<typeof KickPlayerIntentSchema>;
export type TogglePauseIntent = z.infer<typeof TogglePauseIntentSchema>;
export type Turn = z.infer<typeof TurnSchema>;
export type GameConfig = z.infer<typeof GameConfigSchema>;
@@ -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({
+3
View File
@@ -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`);
}
+27
View File
@@ -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 {}
}
+4
View File
@@ -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[];
+11
View File
@@ -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();
}
+9 -1
View File
@@ -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;
}
+4
View File
@@ -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);
}
+6
View File
@@ -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;
}
+54 -1
View File
@@ -62,6 +62,8 @@ export class GameServer {
private kickedClients: Set<ClientID> = new Set();
private outOfSyncClients: Set<ClientID> = new Set();
private isPaused = false;
private websockets: Set<WebSocket> = 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,