Skip to content

@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

Constructors

constructor

new LobbyClient(): LobbyClient

Returns

LobbyClient

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);
  }
}