Frontend

This guide covers the client application’s architecture, patterns, and conventions for developers adding or modifying frontend features.

Tech Stack

ToolVersion/Role
React 18UI framework
TypeScriptType safety
ViteBuild tool and dev server
React Router v7Client-side routing
ZustandGlobal state management
Socket.IO ClientWebSocket communication
Tailwind CSS v4Utility-first styling
i18nextInternationalisation
@poker/sharedShared package (stores, types, utilities)

The client source lives in /client/src/. The React Native mobile app is a separate package under packages/mobile/.

Directory Structure

client/src/
  api/           — REST API client functions
  components/    — Shared UI components
    game/        — Game-specific components (table, cards, chips, HUD)
    admin/       — Admin panel components
    agent/       — Agent portal components
  config/        — Client-side config (request retry settings)
  constants/     — Animation durations, agent permissions
  contexts/      — React contexts (SocketContext)
  hooks/         — Custom hooks
  layouts/       — Layout wrappers (MainLayout, AdminLayout, AuthLayout, AgentLayout)
  lib/           — Utilities: socket type defs, game state mapper, etc.
  pages/         — Page components (one per route)
    admin/       — Admin portal pages
    auth/        — Login, register, forgot password, etc.
    game/        — Poker table page
    lobby/       — Lobby and tournament detail pages
    cashier/     — Deposit/withdrawal page
    affiliate/   — Affiliate dashboard
    agent/       — Agent portal pages
    leaderboard/ — Leaderboard page
    profile/     — Profile page
  routes/        — React Router configuration (index.tsx, lazy-loaders.ts)
  store/         — Zustand stores (re-exports from @poker/shared + web-specific wiring)
  utils/         — Pure utility functions
  main.tsx       — Application entry point
  App.tsx        — Root component with providers

Routing

Routes are defined in client/src/routes/index.tsx using createBrowserRouter from react-router-dom.

Route structure

/                    — HomePage (public landing)
/lobby/:tab?         — LobbyPage (lazy-loaded)
/tournament/:tournamentId — TournamentDetailPage (lazy-loaded)
/table/:tableId      — TablePage (authenticated, fullscreen, no layout wrapper)
/tables/:tableId/watch — SpectatorPage (authenticated)
/profile             — ProfilePage (authenticated, lazy)
/cashier             — CashierPage (authenticated)
/affiliate           — AffiliateDashboardPage (authenticated)
/leaderboard         — LeaderboardPage
/pages/:slug         — PublicPage (CMS pages)
/ext/:providerType/* — PluginPageRenderer (authenticated plugin pages)
/plugins/:providerType/public/* — PluginPageRenderer (public plugin pages)

/login               — LoginPage
/register            — RegisterPage
/forgot-password     — ForgotPasswordPage
/reset-password      — ResetPasswordPage
/verify-email        — VerifyEmailPage

/admin/...           — Admin panel
/agent/...           — Agent portal (authenticated)

Adding a new page

  1. Create a page component in the appropriate subdirectory under client/src/pages/.
  2. Add a lazy import at the top of routes/index.tsx:
    const MyNewPage = lazy(() => import('@/pages/MyNewPage').then(m => ({ default: m.MyNewPage })));
  3. Add the route inside the appropriate children array:
    { path: 'my-path', element: <Lazy><MyNewPage /></Lazy> },
  4. Wrap with <RequireAuth> if authentication is needed.

Layout wrappers

  • MainLayout — header, sidebar navigation, footer. Used for most player-facing pages.
  • AuthLayout — centred card layout for auth forms.
  • AdminLayout — admin sidebar + content area.
  • AgentLayout — agent portal sidebar + content area.

The table page intentionally has no layout wrapper — it renders fullscreen.

Zustand Store Architecture

Zustand is used for global state. Most stores live in @poker/shared (the shared package) and are re-exported from client/src/store/ with web-specific wiring (localStorage adapters, cross-store reset hooks).

Available stores

StorePurpose
useAuthStoreAuthenticated user, tokens, login/logout
useGameStoreCurrent table state (players, cards, pot, actions)
useLobbyStoreAvailable tables, tournaments, filters
useChipAnimationStoreChip animation state (pots, winners)
useToastStoreToast notification queue
useSettingsStoreAdmin settings (read-only cache)
useUserSettingsStorePer-user preferences (sound, language, etc.)
useTableSessionStorePer-table session data (last buy-in, etc.)
useI18nStoreLanguage preference
useConfirmStoreConfirmation dialog state
useWageringProgressStoreBonus wagering progress

Accessing state

Always use selector functions to avoid unnecessary re-renders:

// Specific selector — only re-renders when player's stack changes
const myStack = useGameStore(state => state.players.find(p => p.id === myId)?.stack);

// Pre-built selectors from @poker/shared
import { useGameStore, selectMyPlayer, selectIsMyTurn } from '@/store';
const me = useGameStore(selectMyPlayer);
const isMyTurn = useGameStore(selectIsMyTurn);

Accessing state outside React

const state = useGameStore.getState();
const player = state.players.find(p => p.id === 'abc');

Auth store wiring

The auth store is created with platform-specific adapters:

// client/src/store/authStore.ts
export const useAuthStore = createAuthStore({
  storage: createWebStorageAdapter(),   // localStorage
  onLogin: () => {
    useUserSettingsStore.getState().loadSettings();
  },
  onLogout: () => {
    useUserSettingsStore.getState().clearSettings();
    useGameStore.getState().resetGame();
    useLobbyStore.getState().resetLobby();
    useWageringProgressStore.getState().clearProgress();
  },
});

Cross-store cleanup on logout is wired here — add any new store resets to the onLogout callback.

Custom Hooks

useSocket()

Thin wrapper around SocketContext. Returns:

{
  socket: PokerSocket | null;
  isConnected: boolean;
  userId: string | null;
  sessionId: string | null;
  error: string | null;
  connect: () => void;
  disconnect: () => void;
}

The SocketContext (client/src/contexts/SocketContext.tsx) manages the Socket.IO connection lifecycle. Hooks that need to subscribe to events should use useSocketContext() directly to access subscribe(event, handler) and emit(event, data).

useGameSocket(options)

The primary hook for game table pages. Manages all game-related WebSocket events:

const {
  isConnected,
  isJoined,
  error,
  remoteStyleId,
  joinTable,     // (seatIndex, buyIn, password?) => Promise<boolean>
  leaveTable,    // (keepWatching?) => Promise<{success, cashOut?, error?}>
  sendAction,    // (action, amount?) => Promise<boolean>
  sitOut,
  sitIn,
  useTimeBank,
  addChips,      // (amount) => Promise<boolean>
  tournamentRebuy,
  tournamentAddOn,
  tournamentReentry,
  sendChat,      // (message) => void
} = useGameSocket({ tableId, seatIndex?, buyIn?, password?, autoJoin? });

The hook:

  • Subscribes to the server’s table and hand events (table_state, hole_cards, community_cards, forced_bets, and others).
  • Maps server state (TableState) to store state, using mapGameState() for the phase and mapTablePlayer() for players.
  • Handles showdown display (default duration 4s).
  • Detects reconnections and re-requests table state.
  • Sequences events using lastSequenceRef to handle out-of-order delivery.

useLobbySocket()

Emits subscribe_lobby when connected and keeps useLobbyStore up to date from lobby WebSocket events.

useSpectatorSocket(options)

Read-only counterpart of useGameSocket: returns isWatching, tableState, error, watchTable() and stopWatching() without seating the user. Used by the spectator view.

useHandTransition()

Orchestrates the timeline between hands. It lives in @poker/shared and is used by web and mobile:

  1. hand_result → WINNER phase: badges and payout animation.
  2. CLEANUP phase: community and hole cards exit (cleanupPhaseMs, default 600ms).
  3. IDLE: showdown state cleared and the pot reset.

Events such as game_started that arrive during a transition are deferred until IDLE, which prevents game state flickers between hands.

usePlayerControls()

Returns the set of available actions and bet sizing UI state for the current player’s turn. Wraps selectAvailableActions with bet slider logic.

useIsMobile()

Returns true when the viewport is below the breakpoint (useIsMobile(breakpoint = 640)). Used for responsive layout decisions.

usePermission(feature)

Checks if the authenticated user has access to an admin feature (for example tables:read). Used to conditionally render admin controls. usePermissions(features) and useAnyPermission(features) check several.

WebSocket Integration

All WebSocket communication is typed via client/src/lib/socket.ts. The PokerSocket type extends Socket.IO’s Socket with typed event maps.

Event flow for game actions

User clicks "Bet"
  → sendAction('bet', 500)            (useGameSocket)
  → socket.emit('player_action', ...) (SocketContext.emit)
  → server validates and applies action
  → server emits 'table_state' to the table's sockets
  → useGameSocket receives 'table_state'
  → server state is mapped to store format
  → useGameStore updated
  → React components re-render

Game state mapping

client/src/lib/gameStateMapper.ts holds the helpers that convert server state to store format:

  • parseCard() — converts a card string to a Card object.
  • mapGameState() — maps the server’s game state string to a GamePhase.
  • preserveRevealedCards() — retains card face-up state across state updates to prevent cards flickering back to face-down.

Tailwind CSS Conventions

The project uses Tailwind CSS v4. There is no tailwind.config.js; the theme is defined with @theme in client/src/index.css.

Class conventions

  • Use Tailwind utilities directly on components.
  • Prefer responsive variants (sm:, md:, lg:) over CSS media queries.
  • Do not use CSS modules or styled components. Most styling is Tailwind; a few table and chat surfaces have plain CSS files next to their components (for example client/src/pages/game/V2TablePage.css).
  • Use logical CSS so right-to-left languages work. npm run lint:rtl --prefix client checks this in CI.

Custom design tokens

TokenValue / Purpose
--color-table-green-*Poker table greens (50–950)
--color-card-red-*Red card colours
--color-card-black-*Black card colours
--color-gold-*Gold accents

Component Patterns

Page components

Pages are thin wrappers that compose smaller components and connect to stores/hooks:

// client/src/pages/lobby/LobbyPage.tsx
export function LobbyPage() {
  const { isConnected } = useLobbySocket();
  const tables = selectFilteredTables(useLobbyStore.getState());

  return (
    <div className="container mx-auto p-4">
      {/* compose TableListView, TournamentListView, etc. */}
    </div>
  );
}

Feature components

Complex features live in client/src/components/ and are reused across pages. The table pages themselves are in client/src/pages/game/ (TablePage.tsx, V2TablePage.tsx, PokerTable.tsx, V2PokerTable.tsx).

components/game/
  PlayerSeat.tsx       — individual player display
  PlayingCard.tsx      — single card
  PotDisplay.tsx       — pot display
  ChipAnimation.tsx    — chip animations
  TimerDisplay.tsx     — action timer
  controls/            — action controls (PlayerControlsFactory, ClassicControls, DockControls, V2Controls)
  v2/                  — Table V2 components (V2TableShell, V2PlayerSeat, V2CommunityCards, V2TopBar…)

Loading states

Use the Skeleton component for loading placeholders:

<Skeleton className="h-4 w-32" />   // Inline loading placeholder

Use <RouteLoadingFallback /> as the Suspense fallback for lazy-loaded routes.

Error boundaries

The router’s <Lazy> wrapper puts each lazy route in an ErrorBoundary, so rendering errors are caught and displayed without crashing the whole app.

Internationalisation (i18n)

Translation strings are stored in the database and served by the API at /config/languages/{{lng}}/phrases, loaded through i18next-http-backend. Bundled default packs are in src/data/ (default-phrases-en.json, default-phrases-zh-CN.json). The useTranslation() hook from react-i18next provides access:

import { useTranslation } from 'react-i18next';

function MyComponent() {
  const { t } = useTranslation();
  return <span>{t('lobby.balance.realMoney', 'Real Money')}</span>;
}

Pass the English text as the second argument so the UI has a fallback. useLocale() returns the current locale; the language preference is managed by useI18nStore.

To add a new translation key:

  1. Add it to src/data/default-phrases-en.json.
  2. Add the translation to src/data/default-phrases-zh-CN.json (npm run i18n:validate:zh-cn reports missing keys).
  3. Use t('key', 'English text') in components.

Adding an Admin Page

  1. Create the page in client/src/pages/admin/:
    // client/src/pages/admin/MyAdminPage.tsx export function MyAdminPage() { return <div>My admin content</div>; }
  2. Add a lazy import in routes/index.tsx:
    const MyAdminPage = lazy(() => import('@/pages/admin/MyAdminPage').then(m => ({ default: m.MyAdminPage })));
  3. Add the route inside the admin children:
    { path: 'my-page', element: <Lazy><MyAdminPage /></Lazy> },
  4. Add a sidebar link in layouts/AdminLayout.tsx, with its labelKey, defaultLabel and requiredFeature.
  5. Protect the API route with requireFeature('feature:scope'), using a key from ADMIN_FEATURES in src/api/middleware/auth.ts.

Code Splitting

All admin pages and non-critical player pages are lazy-loaded via React.lazy(). The router wraps them in <Lazy> which uses <Suspense> with <RouteLoadingFallback>.

Avoid importing large admin components in player-facing code paths — this would defeat code splitting.

Important: Do not use NodeJS.Timeout or other @types/node types in client code. The frontend build is isolated and does not include @types/node. Use ReturnType<typeof setTimeout> instead.

Try the live demo Pricing

Have questions about Poker Engine? We reply fast.