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
| Library | Purpose |
|---|---|
| Expo 55 | Cross-platform runtime |
| Expo Router | File-based navigation (files in app/ = routes) |
| Clerk Expo SDK | Authentication (sign-in, sign-up, JWT for REST calls) |
| Socket.IO client | Real-time connection via GameService singleton |
| Rive | Animation 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 testbedState Architecture
There is no centralised Redux or Zustand store. State is split across:
1. GameService — services/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. ConfigContext — contexts/ConfigContext.tsx
Fetches and exposes app config from GET /config. Available via useConfig().
Fields: config: AppConfig (matchConfig, menuButtons, storeItems), isLoading, isFromServer.
3. ModalContext — contexts/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 + gameBandsSocket.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 000msAuthentication
Socket.IO connections are not authenticated with a JWT. Instead:
- Client connects (no auth header)
- Client emits
identify { playerId, name, expoPushToken? }after connection - Server maps
socketId → userIdinPresenceService
The Clerk JWT is only used for REST calls (attached as Authorization: Bearer header). Socket identity relies entirely on the identify event.
Connection Lifecycle
| Event | Trigger | Action |
|---|---|---|
| App starts | _layout.tsx | GameService.getInstance() — singleton created |
| User signs in | index.tsx | gameService.connect() + gameService.identify() |
| User signs out | index.tsx | gameService.disconnect() |
| Network restored | index.tsx | gameService.reconnect() |
| App backgrounds | index.tsx | gameService.emitAppState('background') |
| Socket reconnects | automatic | rejoinGameIfStored() re-emits joinGame with stored matchId/playerId |
Key Files
| File | Purpose |
|---|---|
app/_layout.tsx | Root navigator, GameService init, Clerk setup, notification handlers |
app/index.tsx | Home screen, socket connect/disconnect on auth state change, identify |
services/GameService.ts | Singleton Socket.IO client — all events, emitters, listener management |
contexts/ModalContext.tsx | Global modal management (challenges, friend requests, confirmations) |
contexts/ConfigContext.tsx | App config from server (menu buttons, store items, match config) |
hooks/useMatchState.ts | Game play state — gameState, bands, timer, errors |
components/Match.tsx | Main game UI — coordinates play, word effects, reveals |
app/challenge.tsx | Challenge tab — REST-backed list + socket events |
app/ongoing-games/index.tsx | Match list — REST-paginated, filter/sort state |
app/profile/friends.tsx | Friend invitations and friend list (REST-backed) |