@lands.io/mod-sdk / LobbyClient
Class: LobbyClient¶
Client for communicating with the main Lands.io app from a mod lobby.
Example
const lobby = new LobbyClient();
// Handle initialization
lobby.onInit((data) => {
document.getElementById('mode').textContent = data.gameMode;
document.getElementById('player').textContent = data.playerInfo.name;
});
// Start game when button is clicked
startButton.onclick = () => {
if (lobby.gameMode === 'singleplayer') {
lobby.startSinglePlayer({ map: 'Europe', difficulty: 'Medium' });
} else {
lobby.startMatchmaking();
}
};
Table of contents¶
Constructors¶
Properties¶
Accessors¶
Methods¶
- onFontsReady
- onInit
- onMapThumbnails
- onMatchmakingCancelled
- onPartyError
- onPartyUpdated
- onSetGameMode
- rpc
- startMatchmaking
- startSinglePlayer
Constructors¶
constructor¶
• new LobbyClient(): LobbyClient
Returns¶
Properties¶
party¶
• Readonly party: Object
Type declaration¶
| Name | Type |
|---|---|
create |
() => Promise\<Party> |
join |
(inviteCode: string) => Promise\<Party> |
leave |
() => Promise\<void> |
me |
() => Promise\<null | Party> |
setReady |
(ready: boolean) => Promise\<Party> |
subscribe |
() => Promise\<void> |
unsubscribe |
() => Promise\<void> |
playerProfiles¶
• Readonly playerProfiles: Object
Type declaration¶
| Name | Type |
|---|---|
batch |
(playerIds: string[]) => Promise\<PlayerInfoBatchResult> |
Accessors¶
fontsReady¶
• get fontsReady(): boolean
Whether fonts have been loaded and are ready. Returns true once fonts are registered or after timeout fallback.
Returns¶
boolean
gameMode¶
• get gameMode(): null | "singleplayer" | "multiplayer"
The current game mode. Returns null if not yet initialized.
Returns¶
null | "singleplayer" | "multiplayer"
initData¶
• get initData(): null | LobbyInitData
The current initialization data. Returns null if not yet initialized.
Returns¶
null | LobbyInitData
mapDisplayNames¶
• get mapDisplayNames(): Record\<string, string>
Map display names (e.g., "South America 1"). Returns empty object if display names haven't been received yet.
Returns¶
Record\<string, string>
mapThumbnails¶
• get mapThumbnails(): Record\<string, string>
Map thumbnails as data URLs. Returns empty object if thumbnails haven't been received yet.
Returns¶
Record\<string, string>
maps¶
• get maps(): string[]
The available maps. Returns empty array if not yet initialized.
Returns¶
string[]
modConfig¶
• get modConfig(): null | { id: string ; name: string ; version: number }
The current mod config. Returns null if not yet initialized.
Returns¶
null | { id: string ; name: string ; version: number }
partyError¶
• get partyError(): null | Error
Returns¶
null | Error
playerInfo¶
• get playerInfo(): null | { clanTag?: string ; id: string ; name: string }
The current player info. Returns null if not yet initialized.
Returns¶
null | { clanTag?: string ; id: string ; name: string }
Methods¶
onFontsReady¶
▸ onFontsReady(callback): () => void
Register a callback to be called when fonts are ready. Use this to unblock UI rendering after fonts are loaded.
Parameters¶
| Name | Type | Description |
|---|---|---|
callback |
() => void |
Function to call when fonts are ready |
Returns¶
fn
▸ (): void
Returns¶
void
onInit¶
▸ onInit(callback): () => void
Register a callback to be called when the lobby is initialized.
Parameters¶
| Name | Type | Description |
|---|---|---|
callback |
(data: LobbyInitData) => void |
Function to call with initialization data |
Returns¶
fn
▸ (): void
Returns¶
void
onMapThumbnails¶
▸ onMapThumbnails(callback): () => void
Register a callback to be called when map thumbnails are received.
Parameters¶
| Name | Type | Description |
|---|---|---|
callback |
(thumbnails: Record\<string, string>, displayNames: Record\<string, string>) => void |
Function to call with map thumbnails and display names |
Returns¶
fn
▸ (): void
Returns¶
void
onMatchmakingCancelled¶
▸ onMatchmakingCancelled(callback): () => void
Register a callback to be called when matchmaking is cancelled. Use this to reset the lobby UI (e.g., re-enable the start button).
Parameters¶
| Name | Type | Description |
|---|---|---|
callback |
() => void |
Function to call when matchmaking is cancelled |
Returns¶
fn
▸ (): void
Returns¶
void
onPartyError¶
▸ onPartyError(callback): () => void
Parameters¶
| Name | Type |
|---|---|
callback |
(error: null | Error) => void |
Returns¶
fn
▸ (): void
Returns¶
void
onPartyUpdated¶
▸ onPartyUpdated(callback): () => void
Parameters¶
| Name | Type |
|---|---|
callback |
(party: null | Party) => void |
Returns¶
fn
▸ (): void
Returns¶
void
onSetGameMode¶
▸ onSetGameMode(callback): () => void
Register a callback to be called when the game mode is changed. Use this to update the UI when the user toggles between singleplayer and multiplayer.
Parameters¶
| Name | Type | Description |
|---|---|---|
callback |
(gameMode: "singleplayer" | "multiplayer") => void |
Function to call with the new game mode |
Returns¶
fn
▸ (): void
Returns¶
void
rpc¶
▸ rpc(method, params?, timeoutMs?): Promise\<unknown>
Parameters¶
| Name | Type | Default value |
|---|---|---|
method |
LobbyRpcMethod |
undefined |
params? |
unknown |
undefined |
timeoutMs |
number |
10_000 |
Returns¶
Promise\<unknown>
startMatchmaking¶
▸ startMatchmaking(): void
Start matchmaking to find a multiplayer game.
Returns¶
void
startSinglePlayer¶
▸ startSinglePlayer(config?): void
Start a single player game with the given configuration.
Parameters¶
| Name | Type | Description |
|---|---|---|
config |
SinglePlayerConfig |
Game configuration options |
Returns¶
void
Source Code¶
View full implementation
/**
* Client for communicating with the main Lands.io app from a mod lobby.
*
* @example
* ```typescript
* const lobby = new LobbyClient();
*
* // Handle initialization
* lobby.onInit((data) => {
* document.getElementById('mode').textContent = data.gameMode;
* document.getElementById('player').textContent = data.playerInfo.name;
* });
*
* // Start game when button is clicked
* startButton.onclick = () => {
* if (lobby.gameMode === 'singleplayer') {
* lobby.startSinglePlayer({ map: 'Europe', difficulty: 'Medium' });
* } else {
* lobby.startMatchmaking();
* }
* };
* ```
*/
export class LobbyClient {
private initCallbacks = new Set<(data: LobbyInitData) => void>();
private matchmakingCancelledCallbacks = new Set<() => void>();
private thumbnailsCallbacks = new Set<
(thumbnails: Record<string, string>, displayNames: Record<string, string>) => void
>();
private fontsReadyCallbacks = new Set<() => void>();
private setGameModeCallbacks = new Set<
(gameMode: 'singleplayer' | 'multiplayer') => void
>();
private _initData: LobbyInitData | null = null;
private _mapThumbnails: Record<string, string> = {};
private _mapDisplayNames: Record<string, string> = {};
private _fontsReady = false;
private _fontsLoading = false;
private fontTimeoutId: number | null = null;
private rpcPending = new Map<
string,
{
resolve: (value: unknown) => void;
reject: (reason: Error) => void;
timeoutId: number;
}
>();
private _party: Party | null = null;
private partyUpdatedCallbacks = new Set<(party: Party | null) => void>();
private _partyError: Error | null = null;
private partyErrorCallbacks = new Set<(error: Error | null) => void>();
constructor() {
// Listen for messages from the parent window
window.addEventListener('message', this.handleMessage.bind(this));
// Send lobbyReady signal to parent (listeners are now installed)
this.sendMessage({ type: 'lobbyReady' });
// Fallback: unblock after 3s if fonts never arrive
this.fontTimeoutId = window.setTimeout(() => {
if (!this._fontsReady) {
console.warn(
'[LobbyClient] Font assets timeout - falling back to sans-serif'
);
this._fontsLoading = false;
this._fontsReady = true;
// Send fontsReady even on timeout (UI should render with fallback font)
this.sendMessage({ type: 'fontsReady' });
for (const cb of this.fontsReadyCallbacks) {
cb();
}
}
}, 3000);
}
/**
* The current initialization data.
* Returns null if not yet initialized.
*/
public get initData(): LobbyInitData | null {
return this._initData;
}
/**
* The current game mode.
* Returns null if not yet initialized.
*/
public get gameMode(): 'singleplayer' | 'multiplayer' | null {
return this._initData?.gameMode ?? null;
}
/**
* The current player info.
* Returns null if not yet initialized.
*/
public get playerInfo(): LobbyInitData['playerInfo'] | null {
return this._initData?.playerInfo ?? null;
}
/**
* The current mod config.
* Returns null if not yet initialized.
*/
public get modConfig(): LobbyInitData['modConfig'] | null {
return this._initData?.modConfig ?? null;
}
/**
* The available maps.
* Returns empty array if not yet initialized.
*/
public get maps(): string[] {
return this._initData?.maps ?? [];
}
/**
* Map thumbnails as data URLs.
* Returns empty object if thumbnails haven't been received yet.
*/
public get mapThumbnails(): Record<string, string> {
return this._mapThumbnails;
}
/**
* Map display names (e.g., "South America 1").
* Returns empty object if display names haven't been received yet.
*/
public get mapDisplayNames(): Record<string, string> {
return this._mapDisplayNames;
}
/**
* Whether fonts have been loaded and are ready.
* Returns true once fonts are registered or after timeout fallback.
*/
public get fontsReady(): boolean {
return this._fontsReady;
}
/**
* Register a callback to be called when the lobby is initialized.
*
* @param callback - Function to call with initialization data
*/
public onInit(callback: (data: LobbyInitData) => void): () => void {
this.initCallbacks.add(callback);
// If we already have init data, call immediately
if (this._initData) {
callback(this._initData);
}
return () => {
this.initCallbacks.delete(callback);
};
}
/**
* Register a callback to be called when matchmaking is cancelled.
* Use this to reset the lobby UI (e.g., re-enable the start button).
*
* @param callback - Function to call when matchmaking is cancelled
*/
public onMatchmakingCancelled(callback: () => void): () => void {
this.matchmakingCancelledCallbacks.add(callback);
return () => {
this.matchmakingCancelledCallbacks.delete(callback);
};
}
/**
* Register a callback to be called when map thumbnails are received.
*
* @param callback - Function to call with map thumbnails and display names
*/
public onMapThumbnails(
callback: (
thumbnails: Record<string, string>,
displayNames: Record<string, string>
) => void
): () => void {
this.thumbnailsCallbacks.add(callback);
// If we already have thumbnails, call immediately
if (Object.keys(this._mapThumbnails).length > 0) {
callback(this._mapThumbnails, this._mapDisplayNames);
}
return () => {
this.thumbnailsCallbacks.delete(callback);
};
}
/**
* Register a callback to be called when fonts are ready.
* Use this to unblock UI rendering after fonts are loaded.
*
* @param callback - Function to call when fonts are ready
*/
public onFontsReady(callback: () => void): () => void {
this.fontsReadyCallbacks.add(callback);
// If fonts are already ready, call immediately
if (this._fontsReady) {
callback();
}
return () => {
this.fontsReadyCallbacks.delete(callback);
};
}
/**
* Register a callback to be called when the game mode is changed.
* Use this to update the UI when the user toggles between singleplayer and multiplayer.
*
* @param callback - Function to call with the new game mode
*/
public onSetGameMode(
callback: (gameMode: 'singleplayer' | 'multiplayer') => void
): () => void {
this.setGameModeCallbacks.add(callback);
if (this._initData) {
callback(this._initData.gameMode);
}
return () => {
this.setGameModeCallbacks.delete(callback);
};
}
/**
* Start a single player game with the given configuration.
*
* @param config - Game configuration options
*/
public startSinglePlayer(config: SinglePlayerConfig = {}): void {
this.sendMessage({ type: 'startSinglePlayer', config });
}
/**
* Start matchmaking to find a multiplayer game.
*/
public startMatchmaking(): void {
this.sendMessage({ type: 'startMatchmaking' });
}
public rpc(
method: LobbyRpcMethod,
params?: unknown,
timeoutMs = 10_000
): Promise<unknown> {
const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return new Promise((resolve, reject) => {
const timeoutId = window.setTimeout(() => {
this.rpcPending.delete(id);
reject(new Error(`RPC timeout for ${method}`));
}, timeoutMs);
this.rpcPending.set(id, {
resolve,
reject,
timeoutId,
});
this.sendMessage({ type: 'rpc', id, method, params });
});
}
public readonly party = {
create: async (): Promise<Party> =>
(await this.rpc('party.create')) as Party,
join: async (inviteCode: string): Promise<Party> =>
(await this.rpc('party.join', { inviteCode })) as Party,
leave: async (): Promise<void> => {
await this.rpc('party.leave');
},
setReady: async (ready: boolean): Promise<Party> =>
(await this.rpc('party.ready', { ready })) as Party,
me: async (): Promise<Party | null> =>
(await this.rpc('party.me')) as Party | null,
subscribe: async (): Promise<void> => {
await this.rpc('party.subscribe');
},
unsubscribe: async (): Promise<void> => {
await this.rpc('party.unsubscribe');
},
};
public readonly playerProfiles = {
batch: async (playerIds: string[]): Promise<PlayerInfoBatchResult> =>
(await this.rpc('playerInfo.batch', { playerIds })) as PlayerInfoBatchResult,
};
public onPartyUpdated(callback: (party: Party | null) => void): () => void {
this.partyUpdatedCallbacks.add(callback);
// If we already have party state, emit immediately
callback(this._party);
return () => {
this.partyUpdatedCallbacks.delete(callback);
};
}
public get partyError(): Error | null {
return this._partyError;
}
public onPartyError(callback: (error: Error | null) => void): () => void {
this.partyErrorCallbacks.add(callback);
callback(this._partyError);
return () => {
this.partyErrorCallbacks.delete(callback);
};
}
/**
* Handle messages from the parent window.
*/
private handleMessage(event: MessageEvent<ParentToLobbyMessage>): void {
// Security: only accept messages from parent window
if (event.source !== window.parent) return;
// Explicit local/dev override to bypass origin whitelist checks.
if (allowAllLobbyOrigins()) {
// Continue to message validation/handling below.
} else {
// Security: validate parent origin
const referrerOrigin = getReferrerOrigin();
const isTrustedReferrerOrigin =
referrerOrigin !== null && event.origin === referrerOrigin;
if (!isOriginAllowed(event.origin) && !isTrustedReferrerOrigin) {
console.warn(
'[LobbyClient] Rejected message from unknown origin:',
event.origin
);
return;
}
}
// Basic validation
if (!event.data || typeof event.data !== 'object') return;
const { type } = event.data;
if (type === 'parentReady') {
// Parent is pinging us - respond with lobbyReady
console.log('[LobbyClient] Received parentReady, sending lobbyReady');
this.sendMessage({ type: 'lobbyReady' });
} else if (type === 'init') {
const {
gameMode,
playerInfo,
modConfig,
maps,
publicApiUrl,
parentOrigin: _parentOrigin,
queryParams,
gameEnv,
} = event.data;
// Set the API URL for SDK utilities (ReadonlyModStorage, Clans, etc.)
// This must be done before any SDK code that makes API calls
setPublicApiUrl(publicApiUrl);
this._initData = {
gameMode,
playerInfo,
modConfig,
maps,
publicApiUrl,
parentOrigin: event.origin,
queryParams,
gameEnv,
};
console.log('[LobbyClient] Initialized:', this._initData);
const initData = this._initData;
if (initData) {
for (const cb of this.initCallbacks) {
cb(initData);
}
}
} else if (type === 'rpcResult') {
const pending = this.rpcPending.get(event.data.id);
if (!pending) {
return;
}
this.rpcPending.delete(event.data.id);
clearTimeout(pending.timeoutId);
if (event.data.ok) {
pending.resolve(event.data.result);
} else {
const err = new Error(event.data.error.message);
(err as any).statusCode = event.data.error.statusCode;
pending.reject(err);
}
} else if (type === 'partyUpdated') {
this._party = event.data.party;
if (this._partyError) {
this._partyError = null;
for (const cb of this.partyErrorCallbacks) {
cb(null);
}
}
for (const cb of this.partyUpdatedCallbacks) {
cb(this._party);
}
} else if (type === 'partyPollingError') {
if (!event.data.error) {
if (this._partyError) {
this._partyError = null;
for (const cb of this.partyErrorCallbacks) {
cb(null);
}
}
return;
}
const err = new Error(event.data.error.message);
(err as any).statusCode = event.data.error.statusCode;
this._partyError = err;
for (const cb of this.partyErrorCallbacks) {
cb(err);
}
} else if (type === 'matchmakingCancelled') {
console.log('[LobbyClient] Matchmaking cancelled');
for (const cb of this.matchmakingCancelledCallbacks) {
cb();
}
} else if (type === 'mapThumbnails') {
const { thumbnails, displayNames } = event.data;
this._mapThumbnails = thumbnails;
this._mapDisplayNames = displayNames;
console.log(
'[LobbyClient] Received map thumbnails:',
Object.keys(thumbnails)
);
for (const cb of this.thumbnailsCallbacks) {
cb(thumbnails, displayNames);
}
} else if (type === 'fontAssets') {
// Guard: ignore if fonts already loaded or loading
if (this._fontsReady || this._fontsLoading) {
console.log('[LobbyClient] Ignoring duplicate fontAssets message');
return;
}
// Clear timeout since we got the message
if (this.fontTimeoutId) {
clearTimeout(this.fontTimeoutId);
this.fontTimeoutId = null;
}
// Mark as loading to prevent race conditions
this._fontsLoading = true;
const { fonts } = event.data;
this.loadFonts(fonts);
} else if (type === 'setGameMode') {
const { gameMode } = event.data;
// Update internal state
if (this._initData) {
this._initData.gameMode = gameMode;
}
console.log('[LobbyClient] Game mode changed to:', gameMode);
for (const cb of this.setGameModeCallbacks) {
cb(gameMode);
}
}
}
/**
* Load fonts from ArrayBuffers using FontFace API.
*/
private async loadFonts(fonts: Record<string, ArrayBuffer>): Promise<void> {
try {
// Font descriptors for each font family (weight matches CSS usage)
const fontDescriptors: Record<string, FontFaceDescriptors> = {
LapsusPro: { weight: '700' },
'Big Shoulders Display': { weight: '700' },
};
for (const [fontFamily, buffer] of Object.entries(fonts)) {
// Get descriptors for this font, default to weight 700
const descriptors = fontDescriptors[fontFamily] ?? { weight: '700' };
// Register font with explicit weight descriptor to match CSS
const font = new FontFace(fontFamily, buffer, descriptors);
// Ensure font is fully parsed
await font.load();
// Register it
(document.fonts as any).add(font);
}
// Belt-and-suspenders: wait for document.fonts.ready
await document.fonts.ready;
console.log('[LobbyClient] Fonts registered and ready');
} catch (err) {
console.error('[LobbyClient] Font registration failed:', err);
}
// Mark fonts as ready and clear loading flag
this._fontsLoading = false;
this._fontsReady = true;
// Send fontsReady signal to parent (optional, but useful for diagnostics)
this.sendMessage({ type: 'fontsReady' });
for (const cb of this.fontsReadyCallbacks) {
cb();
}
}
/**
* Send a message to the parent window.
*/
private sendMessage(message: LobbyToParentMessage): void {
if (!window.parent || window.parent === window) {
console.warn('[LobbyClient] Not in an iframe, cannot send message');
return;
}
window.parent.postMessage(message, '*');
console.log('[LobbyClient] Sent message:', message);
}
}