Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion resources/lang/debug.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"disable_nations": "single_modal.disable_nations",
"instant_build": "single_modal.instant_build",
"infinite_gold": "single_modal.infinite_gold",
"random_spawn": "single_modal.random_spawn",
"infinite_troops": "single_modal.infinite_troops",
"disable_nukes": "single_modal.disable_nukes",
"start": "single_modal.start"
Expand Down Expand Up @@ -146,6 +147,7 @@
"bots_disabled": "host_modal.bots_disabled",
"disable_nations": "host_modal.disable_nations",
"instant_build": "host_modal.instant_build",
"random_spawn": "host_modal.random_spawn",
"infinite_gold": "host_modal.infinite_gold",
"infinite_troops": "host_modal.infinite_troops",
"disable_nukes": "host_modal.disable_nukes",
Expand All @@ -166,6 +168,7 @@
"Impossible": "difficulty.Impossible"
},
"heads_up_message": {
"choose_spawn": "heads_up_message.choose_spawn"
"choose_spawn": "heads_up_message.choose_spawn",
"random_spawn": "heads_up_message.random_spawn"
}
}
9 changes: 8 additions & 1 deletion resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@
},
"single_modal": {
"title": "Single Player",
"random_spawn": "Random spawn",
"allow_alliances": "Allow alliances",
"options_title": "Options",
"bots": "Bots: ",
Expand Down Expand Up @@ -262,6 +263,7 @@
"player": "Player",
"players": "Players",
"waiting": "Waiting for players...",
"random_spawn": "Random spawn",
"start": "Start Game",
"host_badge": "Host"
},
Expand Down Expand Up @@ -655,10 +657,15 @@
"copy_clipboard": "Copy to clipboard",
"copied": "Copied!",
"failed_copy": "Failed to copy",
"spawn_failed": {
"title": "Spawn failed",
"description": "Automatic spawn selection failed. You can't play this game."
},
"desync_notice": "You are desynced from other players. What you see might differ from other players."
},
"heads_up_message": {
"choose_spawn": "Choose a starting location"
"choose_spawn": "Choose a starting location",
"random_spawn": "Random spawn is enabled. Selecting starting location for you..."
},
"territory_patterns": {
"title": "Skins",
Expand Down
31 changes: 30 additions & 1 deletion src/client/ClientGameRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
} from "./Transport";
import { createCanvas } from "./Utils";
import { createRenderer, GameRenderer } from "./graphics/GameRenderer";
import { GoToPlayerEvent } from "./graphics/layers/Leaderboard";
import SoundManager from "./sound/SoundManager";

export interface LobbyConfig {
Expand Down Expand Up @@ -314,6 +315,33 @@ export class ClientGameRunner {
if (message.type === "start") {
this.hasJoined = true;
console.log("starting game!");

if (this.gameView.isRandomSpawn()) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

little worried about this if the machine is slow and it takes longer than 1 second to load the game.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored logic there to mitigate this issue.

setTimeout(() => {
const myPlayer = this.gameView.myPlayer();

if (!myPlayer) {
return;
}

if (!myPlayer.hasSpawned()) {
showErrorModal(
"spawn_failed",
translateText("error_modal.spawn_failed.description"),
this.lobby.gameID,
this.lobby.clientID,
true,
false,
translateText("error_modal.spawn_failed.title"),
);

return;
}

this.eventBus.emit(new GoToPlayerEvent(myPlayer));
}, 1000);
}

for (const turn of message.turns) {
if (turn.turnNumber < this.turnsSeen) {
continue;
Expand Down Expand Up @@ -401,7 +429,8 @@ export class ClientGameRunner {
if (
this.gameView.isLand(tile) &&
!this.gameView.hasOwner(tile) &&
this.gameView.inSpawnPhase()
this.gameView.inSpawnPhase() &&
!this.gameView.isRandomSpawn()
) {
this.eventBus.emit(new SendSpawnIntentEvent(tile));
return;
Expand Down
23 changes: 23 additions & 0 deletions src/client/HostLobbyModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export class HostLobbyModal extends LitElement {
@state() private maxTimer: boolean = false;
@state() private maxTimerValue: number | undefined = undefined;
@state() private instantBuild: boolean = false;
@state() private randomSpawn: boolean = false;
@state() private compactMap: boolean = false;
@state() private lobbyId = "";
@state() private copySuccess = false;
Expand Down Expand Up @@ -390,6 +391,22 @@ export class HostLobbyModal extends LitElement {
</div>
</label>

<label
for="random-spawn"
class="option-card ${this.randomSpawn ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="random-spawn"
@change=${this.handleRandomSpawnChange}
.checked=${this.randomSpawn}
/>
<div class="option-card-title">
${translateText("host_modal.random_spawn")}
</div>
</label>

<label
for="donate-gold"
class="option-card ${this.donateGold ? "selected" : ""}"
Expand Down Expand Up @@ -668,6 +685,11 @@ export class HostLobbyModal extends LitElement {
this.putGameConfig();
}

private handleRandomSpawnChange(e: Event) {
this.randomSpawn = Boolean((e.target as HTMLInputElement).checked);
this.putGameConfig();
}

private handleInfiniteGoldChange(e: Event) {
this.infiniteGold = Boolean((e.target as HTMLInputElement).checked);
this.putGameConfig();
Expand Down Expand Up @@ -749,6 +771,7 @@ export class HostLobbyModal extends LitElement {
infiniteTroops: this.infiniteTroops,
donateTroops: this.donateTroops,
instantBuild: this.instantBuild,
randomSpawn: this.randomSpawn,
gameMode: this.gameMode,
disabledUnits: this.disabledUnits,
playerTeams: this.teamCount,
Expand Down
22 changes: 22 additions & 0 deletions src/client/SinglePlayerModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export class SinglePlayerModal extends LitElement {
@state() private maxTimer: boolean = false;
@state() private maxTimerValue: number | undefined = undefined;
@state() private instantBuild: boolean = false;
@state() private randomSpawn: boolean = false;
@state() private useRandomMap: boolean = false;
@state() private gameMode: GameMode = GameMode.FFA;
@state() private teamCount: TeamCountConfig = 2;
Expand Down Expand Up @@ -293,6 +294,22 @@ export class SinglePlayerModal extends LitElement {
</div>
</label>
<label
for="singleplayer-modal-random-spawn"
class="option-card ${this.randomSpawn ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="singleplayer-modal-random-spawn"
@change=${this.handleRandomSpawnChange}
.checked=${this.randomSpawn}
/>
<div class="option-card-title">
${translateText("single_modal.random_spawn")}
</div>
</label>
<label
for="singleplayer-modal-infinite-gold"
class="option-card ${this.infiniteGold ? "selected" : ""}"
Expand Down Expand Up @@ -440,6 +457,10 @@ export class SinglePlayerModal extends LitElement {
this.instantBuild = Boolean((e.target as HTMLInputElement).checked);
}

private handleRandomSpawnChange(e: Event) {
this.randomSpawn = Boolean((e.target as HTMLInputElement).checked);
}

private handleInfiniteGoldChange(e: Event) {
this.infiniteGold = Boolean((e.target as HTMLInputElement).checked);
}
Expand Down Expand Up @@ -563,6 +584,7 @@ export class SinglePlayerModal extends LitElement {
donateTroops: true,
infiniteTroops: this.infiniteTroops,
instantBuild: this.instantBuild,
randomSpawn: this.randomSpawn,
disabledUnits: this.disabledUnits
.map((u) => Object.values(UnitType).find((ut) => ut === u))
.filter((ut): ut is UnitType => ut !== undefined),
Expand Down
4 changes: 3 additions & 1 deletion src/client/graphics/layers/HeadsUpMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ export class HeadsUpMessage extends LitElement implements Layer {
backdrop-blur-md text-white text-md lg:text-xl p-1 lg:p-2"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
>
${translateText("heads_up_message.choose_spawn")}
${this.game.isRandomSpawn()
? translateText("heads_up_message.random_spawn")
: translateText("heads_up_message.choose_spawn")}
</div>
`;
}
Expand Down
3 changes: 3 additions & 0 deletions src/core/GameRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ export class GameRunner {
) {}

init() {
if (this.game.config().randomSpawn()) {
this.game.addExecution(...this.execManager.spawnPlayers());
}
if (this.game.config().bots() > 0) {
this.game.addExecution(
...this.execManager.spawnBots(this.game.config().numBots()),
Expand Down
1 change: 1 addition & 0 deletions src/core/Schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export const GameConfigSchema = z.object({
infiniteGold: z.boolean(),
infiniteTroops: z.boolean(),
instantBuild: z.boolean(),
randomSpawn: z.boolean(),
maxPlayers: z.number().optional(),
maxTimerValue: z.number().int().min(1).max(120).optional(),
disabledUnits: z.enum(UnitType).array().optional(),
Expand Down
1 change: 1 addition & 0 deletions src/core/configuration/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export interface Config {
infiniteTroops(): boolean;
donateTroops(): boolean;
instantBuild(): boolean;
randomSpawn(): boolean;
numSpawnPhaseTurns(): number;
userSettings(): UserSettings;
playerTeams(): TeamCountConfig;
Expand Down
3 changes: 3 additions & 0 deletions src/core/configuration/DefaultConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,9 @@ export class DefaultConfig implements Config {
instantBuild(): boolean {
return this._gameConfig.instantBuild;
}
randomSpawn(): boolean {
return this._gameConfig.randomSpawn;
}
infiniteGold(): boolean {
return this._gameConfig.infiniteGold;
}
Expand Down
5 changes: 5 additions & 0 deletions src/core/execution/ExecutionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { SpawnExecution } from "./SpawnExecution";
import { TargetPlayerExecution } from "./TargetPlayerExecution";
import { TransportShipExecution } from "./TransportShipExecution";
import { UpgradeStructureExecution } from "./UpgradeStructureExecution";
import { PlayerSpawner } from "./utils/PlayerSpawner";

export class Executor {
// private random = new PseudoRandom(999)
Expand Down Expand Up @@ -131,6 +132,10 @@ export class Executor {
return new BotSpawner(this.mg, this.gameID).spawnBots(numBots);
}

spawnPlayers(): Execution[] {
return new PlayerSpawner(this.mg, this.gameID).spawnPlayers();
}

fakeHumanExecutions(): Execution[] {
const execs: Execution[] = [];
for (const nation of this.mg.nations()) {
Expand Down
79 changes: 79 additions & 0 deletions src/core/execution/utils/PlayerSpawner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { Game, PlayerType } from "../../game/Game";
import { TileRef } from "../../game/GameMap";
import { PseudoRandom } from "../../PseudoRandom";
import { GameID } from "../../Schemas";
import { simpleHash } from "../../Util";
import { SpawnExecution } from "../SpawnExecution";

export class PlayerSpawner {
private random: PseudoRandom;
private players: SpawnExecution[] = [];

constructor(
private gm: Game,
gameID: GameID,
) {
this.random = new PseudoRandom(simpleHash(gameID));
}

private randTile(): TileRef {
const x = this.random.nextInt(0, this.gm.width());
const y = this.random.nextInt(0, this.gm.height());

return this.gm.ref(x, y);
}

randomSpawnLand(): TileRef | null {
let tries = 0;

while (tries < 100) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a bit of a naiive solution, as it could easily happen (especially towards the later players) that it won't be able to find a spot just by randomly guessing.
How expensive would it be if we built a low-res grid of possible spawn locations, and then iterated over a random index of those? Such a grid would have 800 elements at most in the world map, but realistically around 600.

i.e. when the map loads, check every 50 tiles verically and horizontally if this is a valid spot, if it is, add it as a tileRef to an array, and then instead of randTile choosing a totally random tile, it will choose possibleSpaces[random.nextInt(0, possibleSpaces.length)]

I don't have the final word on this, just my 5 cents.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you’re right about this approach. However, when I checked how this feature could be implemented based on the existing logic in BotSpawner and FakeHumanExecution, I noticed that the same approach is used in both places. To make it more efficient, we would definitely need to rewrite the logic, but that seems to require a major refactor across multiple components and should probably be addressed separately from the current issue.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also thought about a possible issue: currently, players and bots don’t take the minimum distance between each other into account. Bots only try to spawn farther from other bots, and players from other players, but not players from bots or bots from players.

I’m not sure how inconvenient this behavior might be, but it could be worth improving in future enhancements of this feature if users start reporting issues related to it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, maybe that can be another PR, hopefully 100 tries is enough. I don't think bot proximity should be a big deal, as long as the player has enough space to actually spawn, which should be guaranteed by the tile not being owned.

Copy link
Collaborator

@evanpelle evanpelle Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

checking random land was the fastest way i could come up with, especially on larger maps, tracking available land can be really slow.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eventually we may need smarter spawn selection, for example if there are just 3 players, and 2 players spawn right next to each other they are at a disadvantage.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eventually we may need smarter spawn selection, for example if there are just 3 players, and 2 players spawn right next to each other they are at a disadvantage.

Yes, we could adjust MIN_SPAWN_DISTANCE depending on the number of players in the game, for example. But I’m not sure, maybe some “unfair” spawns aren’t actually a bug, but rather a feature that adds extra tension to the gameplay. Will see with some feedback :)

tries++;

const tile = this.randTile();

if (
!this.gm.isLand(tile) ||
this.gm.hasOwner(tile) ||
this.gm.isBorder(tile)
) {
continue;
}

let tooCloseToOtherPlayer = false;
for (const spawn of this.players) {
if (this.gm.manhattanDist(spawn.tile, tile) < 50) {
tooCloseToOtherPlayer = true;
break;
}
}

if (tooCloseToOtherPlayer) {
continue;
}

return tile;
}

return null;
}

spawnPlayers(): SpawnExecution[] {
for (const player of this.gm.allPlayers()) {
if (player.type() !== PlayerType.Human) {
continue;
}

const spawnLand = this.randomSpawnLand();

if (spawnLand === null) {
// TODO: this should normally not happen, additional logic may be needed, if this occurs
console.warn(`cannot spawn ${player.id}`);
continue;
}

this.players.push(new SpawnExecution(player.info(), spawnLand));
}

return this.players;
}
}
3 changes: 3 additions & 0 deletions src/core/game/GameView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,9 @@ export class GameView implements GameMap {
inSpawnPhase(): boolean {
return this.ticks() <= this._config.numSpawnPhaseTurns();
}
isRandomSpawn(): boolean {
return this._config.randomSpawn();
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you remove this (and on Game), and leave it in the config. so GameView.config().isRandomSpawn()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed and refactored to use from config.

config(): Config {
return this._config;
}
Expand Down
1 change: 1 addition & 0 deletions src/server/GameManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export class GameManager {
infiniteTroops: false,
maxTimerValue: undefined,
instantBuild: false,
randomSpawn: false,
gameMode: GameMode.FFA,
bots: 400,
disabledUnits: [],
Expand Down
3 changes: 3 additions & 0 deletions src/server/GameServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ export class GameServer {
if (gameConfig.instantBuild !== undefined) {
this.gameConfig.instantBuild = gameConfig.instantBuild;
}
if (gameConfig.randomSpawn !== undefined) {
this.gameConfig.randomSpawn = gameConfig.randomSpawn;
}
if (gameConfig.gameMode !== undefined) {
this.gameConfig.gameMode = gameConfig.gameMode;
}
Expand Down
1 change: 1 addition & 0 deletions src/server/MapPlaylist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export class MapPlaylist {
infiniteTroops: false,
maxTimerValue: undefined,
instantBuild: false,
randomSpawn: false,
disableNPCs: mode === GameMode.Team && playerTeams !== HumansVsNations,
gameMode: mode,
playerTeams,
Expand Down
Loading
Loading