Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/smogon/pokemon-showdown-client/llms.txt

Use this file to discover all available pages before exploring further.

Every interactive surface in the Pokémon Showdown client — the main menu, chat rooms, battle screens, the teambuilder, the ladder — is a room. Each room is represented by two objects that live side-by-side: a PSRoom model that holds state and handles server messages, and a PSRoomPanel Preact component that renders that state to the DOM. This model-view split is central to PS’s architecture and makes it straightforward to add new panel types without touching the core layout engine.

PSRoom: the model base class

PSRoom extends PSStreamModel<Args | null> and is defined in client-main.ts. It emits Args tuples when the server sends the room a message, and null to signal a generic re-render (e.g. after a preference change).
export class PSRoom extends PSStreamModel<Args | null> implements RoomOptions {
  id: RoomID;
  title: string;
  type: string;

  /** Identifies which PSRoomPanel subclass renders this room */
  readonly classType: string = '';

  location: PSRoomLocation;  // 'left' | 'right' | 'popup' | 'mini-window' | 'modal-popup'

  /** true if this room type connects to the server */
  readonly canConnect: boolean = false;

  /**
   * Connection state:
   * true = connected, false = not connected,
   * 'autoreconnect' = disconnected, will reconnect,
   * 'client-only' = not a server room (e.g. DMs)
   */
  connected: boolean | 'autoreconnect' | 'client-only' | 'expired' = false;

  /** When true, this room does not push a browser history entry */
  noURL: boolean;

  // Dimensions (updated by PSRoomPanel.updateDimensions)
  width: number;
  height: number;

  /** Called by PSRoomPanel when the panel mounts or a key event fires */
  onParentEvent: ((eventId: 'focus' | 'keydown', e?: Event) => false | void) | null;

  receiveLine(args: Args): void;
  send(msg: string | null, element?: HTMLElement | null): void;
  connect(): void;
}

Room locations

LocationDescription
'left'Main left panel in two-panel mode; the only visible panel in one-panel mode
'right'Right panel in two-panel mode (e.g. the Rooms list, a chat room)
'popup'Non-blocking floating overlay (e.g. user card)
'modal-popup'Blocking modal overlay (e.g. login, options)
'mini-window'Compact embedded widget shown inside the main menu panel

PSRoomPanel: the view base class

PSRoomPanel<T extends PSRoom> is defined in panels.tsx and extends preact.Component<{ room: T }>. Every panel receives its room model as the room prop.
export class PSRoomPanel<T extends PSRoom = PSRoom>
  extends preact.Component<{ room: T }> {

  subscriptions: PSSubscription[];
  wasVisible: boolean;

  /** Subscribe to any model and re-render when it updates */
  subscribeTo<M>(
    model: PSModel<M> | PSStreamModel<M>,
    callback?: (value: M) => void
  ): PSSubscription;

  /** Receives server protocol lines forwarded from the room model */
  receiveLine(args: Args): void;

  /** Focuses the first `.autofocus` element in this panel */
  focus(): void;

  override render(): preact.VNode;
}
PSRoomPanel subscribes to its room in componentDidMount and unsubscribes all subscriptions in componentWillUnmount. The shouldComponentUpdate override skips re-renders when the panel is off-screen — an important optimisation when many rooms are open simultaneously.

The standard pairing pattern

Every panel type follows the same three-step pattern:
1

Define the model

Subclass PSRoom, override classType, and implement receiveLine to handle server messages.
export class ChatRoom extends PSRoom {
  override readonly classType: 'chat' | 'battle' = 'chat';
  override readonly canConnect = true;

  users: { [userid: string]: string } = {};
  log: BattleLog | null = null;

  override receiveLine(args: Args): void {
    switch (args[0]) {
      case 'users': /* ... */ break;
      case 'chat': case 'c': /* ... */ break;
    }
  }
}
2

Define the panel

Subclass PSRoomPanel, set static Model to your room class, and implement render.
class ChatPanel extends PSRoomPanel<ChatRoom> {
  static readonly Model = ChatRoom;
  static readonly location = 'left' as const;

  override render() {
    const { room } = this.props;
    return (
      <PSPanelWrapper room={room}>
        <div class="chat-log" ref={/* ... */} />
        <ChatInput room={room} />
      </PSPanelWrapper>
    );
  }
}
3

Register the panel type

Call PS.addRoomType(ChatPanel) to wire the model and view together. After registration, any room whose type matches will use ChatPanel to render.
PS.addRoomType(ChatPanel);

PSRouter

PSRouter maps browser URLs to room IDs and keeps the browser history in sync with the focused room.
export class PSRouter {
  roomid: RoomID;
  panelState: string;

  /** Extract a RoomID from any PS-related URL, or return null */
  extractRoomID(url: string | null): RoomID | null;

  /**
   * Compute the current URL state.
   * Returns { roomid, changed, newTitle }
   * changed: true = roomid changed, false = panelState changed, null = neither
   */
  updatePanelState(): { roomid: RoomID; changed: boolean | null; newTitle: string };
}

URL extraction rules

extractRoomID handles a wide variety of URL forms and normalises them to a bare room ID:
router.extractRoomID('https://play.pokemonshowdown.com/gen9ou');
// → 'gen9ou'

router.extractRoomID('https://psim.us/r/gen9-ou-12345');
// → 'battle-gen9-ou-12345'

router.extractRoomID('https://replay.pokemonshowdown.com/gen9-ou-12345');
// → 'battle-gen9-ou-12345'

router.extractRoomID('https://psim.us/t/myteamid');
// → 'viewteam-myteamid'
URLs matching common redirects like faq, rules, privacy, dex, and credits return null — the router lets those navigate away from the client entirely.

Layout constants

export const VERTICAL_HEADER_WIDTH = 240;   // px — sidebar width in vertical-nav mode
export const NARROW_MODE_HEADER_WIDTH = 280; // px — scroll offset in narrow (mobile) mode
These constants are used throughout panels.tsx to calculate panel widths and trigger mobile-specific scroll behaviour.

Built-in panel types

MainMenuRoom

classType: 'mainmenu' · location: leftThe home screen. Shows the main action buttons, news mini-window, and search/challenge UI. Always present as PS.mainmenu.

ChatRoom / ChatPanel

classType: 'chat' · location: leftGeneral-purpose chat rooms and private messages. Hosts BattleLog for message rendering and manages the user list.

BattleRoom / BattlePanel

classType: 'battle' · location: left (or right with rightpanelbattles)Extends ChatRoom. Hosts a Battle instance and a BattleScene. The chat log and battle animation share the same panel.

BattlesRoom / BattlesPanel

classType: 'battles' · location: rightShows the list of ongoing battles the user can spectate. Lives in the right panel by default.

TeambuilderRoom

panel id: 'teambuilder' · location: leftThe team editor. Manages curFolder, exportMode, and delegates to PS.teams for persistence. See Teambuilder for details.

LadderFormatRoom

classType: 'ladder' · location: leftDisplays the leaderboard for a specific format. Fetches data from the PS ladder API via Net.

How to extend PSRoom with a custom room type

1

Create the model

export class MyCustomRoom extends PSRoom {
  override readonly classType = 'mycustom';

  // Client commands the UI can trigger via room.send()
  override clientCommands = this.parseClientCommands({
    'doathing'(target) {
      // handle the '/doathing <target>' command locally
      PS.alert(`Doing a thing with: ${target}`);
    },
  });

  override receiveLine(args: Args) {
    switch (args[0]) {
      case 'mycustom-event':
        // handle server-sent events
        this.update(args);
        break;
    }
  }
}
2

Create the panel component

class MyCustomPanel extends PSRoomPanel<MyCustomRoom> {
  static readonly Model = MyCustomRoom;
  static readonly noURL = false;
  static readonly location = 'right' as const;

  override render() {
    const { room } = this.props;
    return (
      <PSPanelWrapper room={room}>
        <div class="pad">
          <h2>{room.title}</h2>
          <button onClick={() => room.send('doathing foo')}>
            Do a Thing
          </button>
        </div>
      </PSPanelWrapper>
    );
  }
}
3

Register and open the room

// Register at startup
PS.addRoomType(MyCustomPanel);

// Open the room (e.g. from a button click)
PS.join('mycustom-room' as RoomID);
clientCommands are handled locally in the browser — they never reach the server. Use room.send('/somecommand') (without the clientCommands override) if you need the command forwarded to the game server.

The noURL flag

Setting static noURL = true on a panel subclass (or passing noURL: true in PS.addRoom()) tells PSRouter not to generate a browser history entry when this room is focused. This is used for modal popups (login, options, avatars) that should not be bookmarkable or affect the back button.
// PSRouter skips history for noURL rooms
if (room.noURL) room = PS.rooms[PS.popups[PS.popups.length - 2]] || PS.panel;

Build docs developers (and LLMs) love