Skip to content

Client App

The client is a React Native + Expo app (apps/client/) using Expo Router for file-based navigation, targeting iOS, Android, and web.

Tech Stack

LibraryPurpose
Expo 55Cross-platform runtime
Expo RouterFile-based navigation (files in app/ = routes)
Clerk Expo SDKAuthentication (sign-in, sign-up, JWT for REST calls)
Socket.IO clientReal-time connection via GameService singleton
RiveAnimation playback

Screen Hierarchy

text
app/
  _layout.tsx           Root layout — Clerk, GameService init, notification handlers
  index.tsx             Home — connect/disconnect socket on auth, identify user
  +not-found.tsx        404 fallback

  login.tsx             Sign-in (Clerk)
  signup.tsx            Sign-up (Clerk)

  new-game.tsx          Choose game mode (Random / Challenge / Singleplayer)
  match.tsx             Game play screen — params: id (matchId), recipientId, recipientName
  ongoing-games/
    index.tsx           Paginated match list with filter/sort, REST-backed
  challenge.tsx         Challenge tab — incoming/outgoing list + friend picker
  profile.tsx           User account — avatar, nickname, level/XP
  profile/
    friends.tsx         Friend management — invitations + friend list (REST-backed)
  statistics.tsx        Win/loss charts, time-series, best letter (REST-cached)
  game-store.tsx        Store UI — collapsible categories (instruments, hats, pets, accessories)
  users.tsx             User listing (debug/admin feature)

  pixel-test.tsx        Visual testbed
  pixel-inspect.tsx     Pixel component inspector
  button-inspect.tsx    Button component inspector
  modal-inspect.tsx     Modal inspector
  chat-testbed.tsx      Chat component testbed
  rive-testbed.tsx      Rive animation testbed

State Architecture

There is no centralised Redux or Zustand store. State is split across:

1. GameServiceservices/GameService.ts

A singleton that owns the Socket.IO client and all real-time event plumbing. Screens subscribe via listener callbacks.

Key internal fields:

typescript
socket: Socket | null
appState: 'active' | 'background' | 'inactive'
lastMatchId: string | null       // persisted for reconnect
lastPlayerId: string | null      // persisted for reconnect
joinGameSentForMatch: string | null  // deduplication guard
// 38+ listener Sets:
connectionListeners, matchFoundListeners, gameAbandonedListeners,
moveErrorListeners, challengeReceivedListeners, friendRequestReceivedListeners,
timerStartedListeners, opponentTypingListeners, ...

2. ConfigContextcontexts/ConfigContext.tsx

Fetches and exposes app config from GET /config. Available via useConfig().

Fields: config: AppConfig (matchConfig, menuButtons, storeItems), isLoading, isFromServer.

3. ModalContextcontexts/ModalContext.tsx

Global modal manager. Listens to GameService for challengeReceived and friendRequestReceived events and auto-shows modals.

Fields: modalState (union: challengeReceived | friendRequestReceived | confirm | null).
Methods: showModal(), hideModal().

4. useMatchState()hooks/useMatchState.ts

Per-game-screen hook. Maintains all in-match state:

typescript
gameState: GameState | null          // from 'gameState' socket event
gameBands: GameBand[]                // from 'gameBands' socket event
queueStatus: QueueStatusPayload | null
pendingMoveBandName: string | null   // optimistic local state
timerSecondsLeft: number | null
timerEndAtMs: number | null
errorMessage, lastMoveError, loadMatchError, rematchError
displayBands: BandRow[]              // computed from gameState + gameBands

Socket.IO Client

Connection

text
URL: process.env.EXPO_PUBLIC_GAME_SERVER_URL || 'http://192.168.50.22:3000'
transports: ['websocket', 'polling']
reconnectionAttempts: 10
reconnectionDelay: 1000ms (exponential backoff)
timeout: 10 000ms

Authentication

Socket.IO connections are not authenticated with a JWT. Instead:

  1. Client connects (no auth header)
  2. Client emits identify { playerId, name, expoPushToken? } after connection
  3. Server maps socketId → userId in PresenceService

The Clerk JWT is only used for REST calls (attached as Authorization: Bearer header). Socket identity relies entirely on the identify event.

Connection Lifecycle

EventTriggerAction
App starts_layout.tsxGameService.getInstance() — singleton created
User signs inindex.tsxgameService.connect() + gameService.identify()
User signs outindex.tsxgameService.disconnect()
Network restoredindex.tsxgameService.reconnect()
App backgroundsindex.tsxgameService.emitAppState('background')
Socket reconnectsautomaticrejoinGameIfStored() re-emits joinGame with stored matchId/playerId

Key Files

FilePurpose
app/_layout.tsxRoot navigator, GameService init, Clerk setup, notification handlers
app/index.tsxHome screen, socket connect/disconnect on auth state change, identify
services/GameService.tsSingleton Socket.IO client — all events, emitters, listener management
contexts/ModalContext.tsxGlobal modal management (challenges, friend requests, confirmations)
contexts/ConfigContext.tsxApp config from server (menu buttons, store items, match config)
hooks/useMatchState.tsGame play state — gameState, bands, timer, errors
components/Match.tsxMain game UI — coordinates play, word effects, reveals
app/challenge.tsxChallenge tab — REST-backed list + socket events
app/ongoing-games/index.tsxMatch list — REST-paginated, filter/sort state
app/profile/friends.tsxFriend invitations and friend list (REST-backed)