diff --git a/resources/lang/en.json b/resources/lang/en.json
index 25946b50e..da20af064 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -363,6 +363,10 @@
"ffa": "Free for All",
"teams": "Teams"
},
+ "public_game_modifier": {
+ "random_spawn": "Random Spawn",
+ "compact_map": "Compact Map"
+ },
"select_lang": {
"title": "Select Language"
},
diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts
index 639ff1344..e3058b627 100644
--- a/src/client/HostLobbyModal.ts
+++ b/src/client/HostLobbyModal.ts
@@ -14,6 +14,7 @@ import {
UnitType,
mapCategories,
} from "../core/game/Game";
+import { getCompactMapNationCount } from "../core/game/NationCreation";
import { UserSettings } from "../core/game/UserSettings";
import {
ClientInfo,
@@ -944,6 +945,7 @@ export class HostLobbyModal extends LitElement {
/**
* Returns the effective nation count for display purposes.
* In HumansVsNations mode, this equals the number of human players.
+ * For compact maps, only 25% of nations are used.
* Otherwise, it uses the manifest nation count (or 0 if nations are disabled).
*/
private getEffectiveNationCount(): number {
@@ -953,7 +955,7 @@ export class HostLobbyModal extends LitElement {
if (this.gameMode === GameMode.Team && this.teamCount === HumansVsNations) {
return this.clients.length;
}
- return this.nationCount;
+ return getCompactMapNationCount(this.nationCount, this.compactMap);
}
}
diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts
index 4caaab182..52cc2e2ab 100644
--- a/src/client/PublicLobby.ts
+++ b/src/client/PublicLobby.ts
@@ -7,6 +7,7 @@ import {
GameMode,
hasUnusualThumbnailSize,
HumansVsNations,
+ PublicGameModifiers,
Quads,
Trios,
} from "../core/game/Game";
@@ -114,6 +115,10 @@ export class PublicLobby extends LitElement {
: `${modeLabel} ${teamDetailLabel}`;
}
+ const modifierLabel = this.getModifierLabels(
+ lobby.gameConfig.publicGameModifiers,
+ );
+
const mapImageSrc = this.mapImages.get(lobby.gameID);
const isUnusualThumbnailSize = hasUnusualThumbnailSize(
lobby.gameConfig.gameMap,
@@ -156,17 +161,29 @@ export class PublicLobby extends LitElement {
.join("")}`
: translateText("public_lobby.join")}
-
-
- ${fullModeLabel}
-
-
- ${translateText(
- `map.${lobby.gameConfig.gameMap
- .toLowerCase()
- .replace(/[\s.]+/g, "")}`,
- )}
-
+
+ ${fullModeLabel}
+ ${modifierLabel.map(
+ (label) =>
+ html`${label}`,
+ )}
+ ${translateText(
+ `map.${lobby.gameConfig.gameMap.toLowerCase().replace(/[\s.]+/g, "")}`,
+ )}
@@ -293,6 +310,22 @@ export class PublicLobby extends LitElement {
return { label: null, isFullLabel: false };
}
+ private getModifierLabels(
+ publicGameModifiers: PublicGameModifiers | undefined,
+ ): string[] {
+ if (!publicGameModifiers) {
+ return [];
+ }
+ const labels: string[] = [];
+ if (publicGameModifiers.isRandomSpawn) {
+ labels.push(translateText("public_game_modifier.random_spawn"));
+ }
+ if (publicGameModifiers.isCompact) {
+ labels.push(translateText("public_game_modifier.compact_map"));
+ }
+ return labels;
+ }
+
private lobbyClicked(lobby: GameInfo) {
if (this.isButtonDebounced) return;
diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts
index fc520af80..291a94321 100644
--- a/src/core/Schemas.ts
+++ b/src/core/Schemas.ts
@@ -171,6 +171,12 @@ export const GameConfigSchema = z.object({
gameType: z.enum(GameType),
gameMode: z.enum(GameMode),
gameMapSize: z.enum(GameMapSize),
+ publicGameModifiers: z
+ .object({
+ isCompact: z.boolean(),
+ isRandomSpawn: z.boolean(),
+ })
+ .optional(),
disableNations: z.boolean(),
bots: z.number().int().min(0).max(400),
infiniteGold: z.boolean(),
diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts
index f05bccda6..96a7938ef 100644
--- a/src/core/configuration/Config.ts
+++ b/src/core/configuration/Config.ts
@@ -7,6 +7,7 @@ import {
Gold,
Player,
PlayerInfo,
+ PublicGameModifiers,
Team,
TerraNullius,
Tick,
@@ -34,6 +35,7 @@ export interface ServerConfig {
map: GameMapType,
mode: GameMode,
numPlayerTeams: TeamCountConfig | undefined,
+ isCompactMap?: boolean,
): number;
numWorkers(): number;
workerIndex(gameID: GameID): number;
@@ -57,6 +59,8 @@ export interface ServerConfig {
stripePublishableKey(): string;
allowedFlares(): string[] | undefined;
enableMatchmaking(): boolean;
+ getRandomPublicGameModifiers(): PublicGameModifiers;
+ supportsCompactMapForTeams(map: GameMapType): boolean;
}
export interface NukeMagnitude {
diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts
index 80fc254e5..4a8f3d8c5 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -12,6 +12,7 @@ import {
Player,
PlayerInfo,
PlayerType,
+ PublicGameModifiers,
Quads,
TerrainType,
TerraNullius,
@@ -176,11 +177,16 @@ export abstract class DefaultServerConfig implements ServerConfig {
map: GameMapType,
mode: GameMode,
numPlayerTeams: TeamCountConfig | undefined,
+ isCompactMap?: boolean,
): number {
const [l, m, s] = numPlayersConfig[map] ?? [50, 30, 20];
const r = Math.random();
const base = r < 0.3 ? l : r < 0.6 ? m : s;
let p = Math.min(mode === GameMode.Team ? Math.ceil(base * 1.5) : base, l);
+ // Apply compact map 75% player reduction
+ if (isCompactMap) {
+ p = Math.max(3, Math.floor(p * 0.25));
+ }
if (numPlayerTeams === undefined) return p;
switch (numPlayerTeams) {
case Duos:
@@ -218,6 +224,20 @@ export abstract class DefaultServerConfig implements ServerConfig {
enableMatchmaking(): boolean {
return false;
}
+
+ getRandomPublicGameModifiers(): PublicGameModifiers {
+ return {
+ isRandomSpawn: Math.random() < 0.1, // 10% chance
+ isCompact: Math.random() < 0.05, // 5% chance
+ };
+ }
+
+ supportsCompactMapForTeams(map: GameMapType): boolean {
+ // Maps with smallest player count < 50 don't support compact map in team games
+ // The smallest player count is the 3rd number in numPlayersConfig
+ const [, , smallest] = numPlayersConfig[map] ?? [50, 30, 20];
+ return smallest >= 50;
+ }
}
export class DefaultConfig implements Config {
diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts
index 8686d7a83..cefcc90e8 100644
--- a/src/core/game/Game.ts
+++ b/src/core/game/Game.ts
@@ -193,6 +193,11 @@ export enum GameMapSize {
Normal = "Normal",
}
+export interface PublicGameModifiers {
+ isCompact: boolean;
+ isRandomSpawn: boolean;
+}
+
export interface UnitInfo {
cost: (game: Game, player: Player) => Gold;
// Determines if its owner changes when its tile is conquered.
diff --git a/src/core/game/NationCreation.ts b/src/core/game/NationCreation.ts
index 076ccfd72..3e4d15807 100644
--- a/src/core/game/NationCreation.ts
+++ b/src/core/game/NationCreation.ts
@@ -2,6 +2,7 @@ import { PseudoRandom } from "../PseudoRandom";
import { GameStartInfo } from "../Schemas";
import {
Cell,
+ GameMapSize,
GameMode,
GameType,
HumansVsNations,
@@ -14,6 +15,7 @@ import { Nation as ManifestNation } from "./TerrainMapLoader";
/**
* Creates the nations array for a game, handling HumansVsNations mode specially.
* In HumansVsNations mode, the number of nations matches the number of human players to ensure fair gameplay.
+ * For compact maps, only 25% of the nations are used.
*/
export function createNationsForGame(
gameStart: GameStartInfo,
@@ -31,13 +33,23 @@ export function createNationsForGame(
new PlayerInfo(n.name, PlayerType.Nation, null, random.nextID()),
);
+ const isCompactMap = gameStart.config.gameMapSize === GameMapSize.Compact;
+
const isHumansVsNations =
gameStart.config.gameMode === GameMode.Team &&
gameStart.config.playerTeams === HumansVsNations;
- // For non-HumansVsNations modes, simply use the manifest nations
+ // For compact maps, use only 25% of nations (minimum 1)
+ let effectiveNations = manifestNations;
+ if (isCompactMap && !isHumansVsNations) {
+ const targetCount = getCompactMapNationCount(manifestNations.length, true);
+ const shuffled = random.shuffleArray(manifestNations);
+ effectiveNations = shuffled.slice(0, targetCount);
+ }
+
+ // For non-HumansVsNations modes, simply use the effective nations
if (!isHumansVsNations) {
- return manifestNations.map(toNation);
+ return effectiveNations.map(toNation);
}
// HumansVsNations mode: balance nation count to match human count
@@ -71,6 +83,20 @@ export function createNationsForGame(
return nations;
}
+// For compact maps, only 25% of nations are used (minimum 1).
+export function getCompactMapNationCount(
+ manifestNationCount: number,
+ isCompactMap: boolean,
+): number {
+ if (manifestNationCount === 0) {
+ return 0;
+ }
+ if (isCompactMap) {
+ return Math.max(1, Math.floor(manifestNationCount * 0.25));
+ }
+ return manifestNationCount;
+}
+
const PLURAL_NOUN = Symbol("plural!");
const NOUN = Symbol("noun!");
diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts
index 361fa332e..c7bbda1e8 100644
--- a/src/server/MapPlaylist.ts
+++ b/src/server/MapPlaylist.ts
@@ -93,25 +93,43 @@ export class MapPlaylist {
const playerTeams =
mode === GameMode.Team ? this.getTeamCount() : undefined;
+ let { isCompact, isRandomSpawn } = config.getRandomPublicGameModifiers();
+
+ // Duos, Trios, and Quads should not get random spawn (as it defeats the purpose)
+ if (
+ playerTeams === Duos ||
+ playerTeams === Trios ||
+ playerTeams === Quads
+ ) {
+ isRandomSpawn = false;
+ }
+
+ // Maps with smallest player count < 50 don't support compact map in team games
+ // The smallest player count is the 3rd number in numPlayersConfig
+ if (mode === GameMode.Team && !config.supportsCompactMapForTeams(map)) {
+ isCompact = false;
+ }
+
// Create the default public game config (from your GameManager)
return {
donateGold: mode === GameMode.Team,
donateTroops: mode === GameMode.Team,
gameMap: map,
- maxPlayers: config.lobbyMaxPlayers(map, mode, playerTeams),
+ maxPlayers: config.lobbyMaxPlayers(map, mode, playerTeams, isCompact),
gameType: GameType.Public,
- gameMapSize: GameMapSize.Normal,
+ gameMapSize: isCompact ? GameMapSize.Compact : GameMapSize.Normal,
+ publicGameModifiers: { isCompact, isRandomSpawn },
difficulty:
playerTeams === HumansVsNations ? Difficulty.Hard : Difficulty.Easy,
infiniteGold: false,
infiniteTroops: false,
maxTimerValue: undefined,
instantBuild: false,
- randomSpawn: false,
+ randomSpawn: isRandomSpawn,
disableNations: mode === GameMode.Team && playerTeams !== HumansVsNations,
gameMode: mode,
playerTeams,
- bots: 400,
+ bots: isCompact ? 100 : 400,
spawnImmunityDuration: 5 * 10,
disabledUnits: [],
} satisfies GameConfig;
diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts
index 330ba91ed..3199e5b81 100644
--- a/tests/util/TestServerConfig.ts
+++ b/tests/util/TestServerConfig.ts
@@ -1,6 +1,6 @@
import { JWK } from "jose";
import { GameEnv, ServerConfig } from "../../src/core/configuration/Config";
-import { GameMapType } from "../../src/core/game/Game";
+import { GameMapType, PublicGameModifiers } from "../../src/core/game/Game";
import { GameID } from "../../src/core/Schemas";
export class TestServerConfig implements ServerConfig {
@@ -82,4 +82,10 @@ export class TestServerConfig implements ServerConfig {
gitCommit(): string {
throw new Error("Method not implemented.");
}
+ getRandomPublicGameModifiers(): PublicGameModifiers {
+ return { isCompact: false, isRandomSpawn: false };
+ }
+ supportsCompactMapForTeams(map: GameMapType): boolean {
+ throw new Error("Method not implemented.");
+ }
}