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.

The Pokémon Showdown Client is structured around three interlocking ideas: a phased loading strategy that keeps first-paint fast, a reactive model system built on a lightweight Observable pattern, and a panel/room framework that maps every piece of UI — from the main menu to a live battle — onto a uniform abstraction. Understanding how these three ideas fit together makes it much easier to navigate the codebase and contribute changes without breaking things unexpectedly.

The Four Loading Phases

The client splits its JavaScript into four distinct phases. Each phase depends on the previous one but is otherwise loaded independently. This structure keeps the initial page load lightweight and defers heavyweight assets (jQuery, battle data, sprite sheets) until the user actually needs them.
1
Phase 1 — Background & Core
2
The very first thing the client loads. No external dependencies.
3
FileResponsibilityclient.cssBasic page styling and layoutclient-core.tsPolyfills, PSModel / PSStreamModel base classes, background model and view
4
client-core.ts is intentionally dependency-free. It sets up the model infrastructure and renders the background image as quickly as possible, so users see a styled page rather than a blank white screen before the heavier bundles arrive.
5
Phase 2 — Basic UI, Connection & Panels
6
The main client shell. By the end of this phase the page is interactive: the user can see the main menu, the header, and an initial connection to the game server is established.
7
FileResponsibilityfont-awesome.cssIcon fontclient-main.tsPSPrefs, PSTeams, PSUser, PSRoom, and the top-level PS singletonSockJSWebSocket-compatible transportclient-connection.tsConnect to the game serverPreactMinimal 3 KB UI library (replaces jQuery in the new client)BattleSoundSound engine (loaded early for low-latency audio)panel-mainmenu.tsxMain menu panelpanel-rooms.tsxRoom list panelpanels.tsxPSRouter, global event listeners, and the root PSView component
8
Phase 3 — Lightweight Panels
9
Panels that are almost always needed but are slightly heavier than the core shell.
10
FileResponsibilityCaja (html-css-sanitizer)HTML sanitizer for user-generated chat contentpanel-chat.tsxChat room panel (ChatRoom)panel-ladder.tsxLadder / ratings panel
11
Phase 4 — Heavyweight Panels (on demand)
12
Phase 4 does not load automatically. It loads only when the user opens a battle room or the team builder. This is where all the large data files and jQuery live.
13
FileResponsibilitybattle.css, sim-types.css, utilichart.cssBattle and team-builder stylesData filesPokédex, moves, items, abilities, type chart, search indexjQueryDOM manipulation library (only used in Phase 4)panel-battle.tsxBattle room panelpanel-teambuilder.tsxTeam builder panelbattle-dex.tsIn-browser Pokédex lookup (Dex)battle.tsBattle animation and replay engine (MIT-licensed)
jQuery is only loaded in Phase 4. All code in Phases 1–3 interacts with the DOM directly, without jQuery. When writing code for earlier phases, make sure not to use jQuery APIs and avoid constructs that crash in IE 9 (the minimum target for non-replay code).

The Observable Model System

State management in the Pokémon Showdown Client is built around three classes defined in client-core.ts. They implement a lightweight Observable pattern similar to RxJS, but without the library overhead.
PSSubscription is returned whenever you subscribe to a model. Hold onto the reference if you need to unsubscribe later (e.g., when a panel unmounts).
export class PSSubscription<T = any> {
  observable: PSModel<T> | PSStreamModel<T>;
  listener: (value: T) => void;

  constructor(
    observable: PSModel<T> | PSStreamModel<T>,
    listener: (value: T) => void
  ) {
    this.observable = observable;
    this.listener = listener;
  }

  unsubscribe() {
    const index = this.observable.subscriptions.indexOf(this as any);
    if (index >= 0) this.observable.subscriptions.splice(index, 1);
  }
}
PSModel is the base for any piece of state that panels want to react to. Calling update() fans out to all current subscribers. Unlike React’s usual paradigm, PS models are mutable — you mutate the model then call update().
export class PSModel<T = null> {
  subscriptions: PSSubscription<T>[] = [];

  subscribe(listener: (value: T) => void): PSSubscription<T> { /* ... */ }

  // Subscribe and immediately invoke the listener once
  subscribeAndRun(listener: (value: T) => void, value?: T): PSSubscription<T> { /* ... */ }

  // Notify all subscribers
  update(value?: T): void { /* ... */ }
}
PSPrefs, PSTeams, PSUser, and the top-level PS singleton all extend PSModel.
PSStreamModel is used for things that emit a sequence of events (not just state snapshots). The key difference from PSModel is the backlog: events emitted before the first subscriber is attached are buffered and replayed when a subscriber arrives. Once drained, the backlog is set to null.
export class PSStreamModel<T = string> {
  subscriptions: PSSubscription<T>[] = [];
  backlog: NonNullable<T>[] | null = [];

  subscribe(listener: (value: T) => void): PSSubscription<T> { /* ... */ }
  update(value: T): void { /* ... */ }
}
PSRoom extends PSStreamModel<Args | null>, which is how server messages flow from the connection layer into each room’s panel.

The Room & Panel System

Every piece of UI in the client — main menu, chat rooms, battles, the team builder, popups — is modelled as a room with an associated panel.

PSRoom (model)

PSRoom is the model class for a single room. It extends PSStreamModel<Args | null>, meaning it can stream parsed server message arguments to its panel. Defined in client-main.ts:
export class PSRoom extends PSStreamModel<Args | null> implements RoomOptions {
  id: RoomID;
  title = "";
  type = '';
  location: PSRoomLocation = 'left'; // 'left' | 'right' | 'popup' | 'mini-window' | 'modal-popup'
  connected: 'autoreconnect' | 'client-only' | 'expired' | boolean = false;
  readonly canConnect: boolean = false;
  width = 0;
  height = 0;
  focusNextUpdate = false;
  notifications: PSNotificationState[] = [];
  noURL: boolean;
  // ...
}
Rooms have a location (left, right, popup, mini-window, modal-popup) that controls how they are positioned in the layout. The top-level PS singleton tracks all open rooms in PS.rooms (a roomid → PSRoom map) and the currently focused room in PS.room.

PSRoomPanel (view)

PSRoomPanel is the Preact component base class for every room’s view. It lives in panels.tsx and handles subscriptions, visibility optimization, and focus management:
export class PSRoomPanel<T extends PSRoom = PSRoom>
  extends preact.Component<{ room: T }> {

  subscriptions: PSSubscription[] = [];

  // Helper: subscribe to a model and re-render on updates.
  // Defaults to calling forceUpdate() if no callback is provided.
  subscribeTo<M>(
    model: PSModel<M> | PSStreamModel<M>,
    callback: (value: M) => void = () => { this.forceUpdate(); }
  ): PSSubscription { /* ... */ }

  // Called for each server message line emitted by the room model
  receiveLine(args: Args) {}

  // shouldComponentUpdate: skip re-render when the panel is off-screen
  override shouldComponentUpdate(): boolean { /* ... */ }

  // Cleans up all subscriptions on unmount
  override componentWillUnmount() { /* ... */ }
}
Each room type is implemented as two separate classes: a model class (e.g. ChatRoom) that extends PSRoom, and a panel class (e.g. ChatPanel) that extends PSRoomPanel and holds a reference to the model via its room prop. This separation keeps state management and rendering concerns distinct.

Registering Room Types

Room types are registered with PS.addRoomType(). Each registration associates a string type identifier with a PSRoomPanel subclass (the panel class, not the room model):
// Example: how panels register themselves (from panel-chat.tsx)
PS.addRoomType(ChatPanel);
The PS singleton then uses these registrations to instantiate the correct panel when the server tells the client to join a room.

The URL Router (PSRouter)

PSRouter (defined in panels.tsx) keeps the browser address bar in sync with which room is currently focused. It is instantiated as a singleton (PS.router = new PSRouter()) at the end of panels.tsx.
export class PSRouter {
  roomid = '' as RoomID;
  panelState = '';

  // Maps URLs from replay.pokemonshowdown.com, psim.us, teams.pokemonshowdown.com
  // and play.pokemonshowdown.com into roomid strings
  extractRoomID(url: string | null): RoomID | null { /* ... */ }

  // When PS.room changes, push or replace a history entry
  subscribeHistory(): void { /* ... */ }

  // Fallback for file:// pages — uses hash-based routing instead
  subscribeHash(): void { /* ... */ }
}
On the live site, PSRouter uses history.pushState and history.replaceState so room navigation produces real URL paths (/battle-gen9ou-12345, /teambuilder, etc.) and back/forward buttons work as expected.
The router also handles cross-domain URL mapping. Links from replay.pokemonshowdown.com/gen9ou-12345, psim.us/r/gen9ou-12345, and teams.pokemonshowdown.com/view/12345 are all mapped to the appropriate internal room ID (battle-gen9ou-12345, viewteam-12345, etc.) by extractRoomID().

Multi-Site Structure

The same client codebase serves multiple subdomains via different entry-point HTML files and config objects.
SitePurpose
play.pokemonshowdown.comMain client — battles, chat, ladder, team builder
replay.pokemonshowdown.comReplay viewer (battle-*.ts files, MIT-licensed)
teams.pokemonshowdown.comTeam sharing and import
The Config.routes object (typed as PSConfig['routes'] in client-main.ts) provides the correct hostname for each sub-site at runtime, allowing a single build to be used across all of them without hard-coded URLs scattered through the source.

Phased Loading Rationale

The combined weight of jQuery, the battle animation engine (battle.ts), and the Pokémon data files (Pokédex, moves, items, abilities, sprite mappings) is substantial. The vast majority of sessions never open a battle room during that visit — players may just browse chat, check the ladder, or build a team without fighting. Loading all of that data eagerly would make every page load slower for every user, even those who never trigger a battle.By gating Phase 4 behind user intent (clicking “Battle!” or joining a battle room), the client keeps its critical-path bundle small. Phases 1 through 3 give you a fully functional chat client and team builder with a fraction of the total asset weight.
The client targets ES3 for maximum compatibility — IE 9 for the main client, and IE 7 for battle replays. Babel 7 compiles TypeScript and modern JavaScript syntax down to ES3, but some runtime features cannot be polyfilled at ES3 without unacceptable overhead:
  • Map and Set — polyfillable, but plain Object (and Object.create(null) for null-prototype objects) is faster and simpler.
  • async/await — not compilable to ES3. Promise chains are fine.
  • Generators and non-Array iterables — too much overhead; for-of on Array compiles to zero overhead.
A small set of polyfills is included for Array#includes, Array.isArray, String#startsWith, String#endsWith, String#includes, String#trim, Object.assign, and Object.create. These are optimized for speed over strict spec compliance.

Next Steps

Quickstart

Clone the repo, run the build, and open the test client.

Introduction

Features, browser support, and licensing overview.

Build docs developers (and LLMs) love