Server
The backend is a NestJS 11 application (apps/server/) using TypeScript. It exposes a REST API and two Socket.IO gateways.
Module Map
| Module | Path | Responsibility |
|---|---|---|
auth | src/auth/ | Clerk JWT validation; guards for REST and Socket endpoints |
game | src/game/ | Core game logic: move validation, state, timers, reveal, rematch |
game-config | src/game-config/ | Reading/writing appConfig key-value settings |
matches | src/matches/ | Match REST endpoints and Clerk name enrichment |
matchmaking | src/matchmaking/ | Queue management, player matching, challenge flow, rematch |
challenges | src/challenges/ | Challenge invitation persistence |
friends | src/friends/ | Friend relationships and invitation management |
spotify | src/spotify/ | Spotify API integration for band name validation |
statistics | src/statistics/ | Aggregated stats and time-series data |
users | src/users/ | User profiles |
xp | src/xp/ | XP totals, leveling, reward event ledger |
presence | src/presence/ | Online/offline status; socket-to-userId mapping |
notifications | src/notifications/ | Push notification dispatch |
push | src/push/ | Expo push token management |
database | src/database/ | Drizzle ORM setup and connection |
Key Service Files
| File | Purpose |
|---|---|
game/game.service.ts | Move validation, band chain rules, game state transitions, timer, timeout |
game/game.gateway.ts | Socket.IO event handlers for all in-match events |
game/game-state-cache.service.ts | In-memory game state cache (5 min TTL) |
game/constants.ts | BOT_ID, BOT_DISPLAY_NAME, timer constants |
matchmaking/matchmaking.service.ts | In-memory queue (Map), 2-player matching |
matchmaking/matchmaking.gateway.ts | Socket.IO events: identify, queue, challenge, rematch |
matchmaking/match-start.service.ts | Match document creation and player notification |
matchmaking/rematch.service.ts | Rematch creation (reorders players, same gameMode) |
spotify/spotify.service.ts | Spotify search, "The" prefix handling, token refresh |
xp/xp.service.ts | XP award, discovery bonus check, level computation |
xp/xp.utils.ts | xpForLevel(), levelFromXp(), xpToNextLevel() |
Database Schema
Managed with Drizzle ORM. Migrations are in apps/server/neon/.
| Table | Key Columns | Purpose |
|---|---|---|
matches | id, players[], status, currentTurn, moves[], maxTurnTimeMs, winner | Active and historical game state |
bands | id, matchId, playerId, bandName, isValid, revealed, round, followerCount | Each band played; id format {gameId}_{timestamp}_{random9} |
friends | userId, friendId | Accepted friend relationships (unique constraint) |
friendInvitations | fromUserId, toUserId, status | Pending/accepted/declined friend requests |
challenges | id, fromUserId, toUserId, matchId, status | Challenge invitations linked to matches |
appConfig | key, value (JSON) | Global app settings — keys: 'match-config', 'menu-buttons', 'store-items', 'xp-config' |
userXp | userId, totalXp, level | XP totals and cached level per user |
rewardEvents | userId, eventType, matchId | Ledger preventing double-awarding |
items | id, name, description, type | Item catalog |
userItems | userId, itemId, quantity | User inventory |
pushTokens | userId, token, platform | Expo push notification tokens |
Game State Cache
GameStateCacheService — in-memory only, not Redis.
| Property | Value |
|---|---|
| Storage | Map<string, CachedGameState> |
| TTL | 5 minutes (300 000 ms) |
| Cleanup interval | 1 minute (60 000 ms) |
| Cache miss | Falls back to database fetch |
On cache miss, Firestore timestamps are converted to JS Date objects and legacy fields are backfilled:
move.validmissing →truemove.roundmissing →1gameState.currentRoundmissing →1
Cache is invalidated when player socketId changes or match document is updated.
Matchmaking Queue
MatchmakingService — in-memory only, not Redis.
typescript
queues: Map<string, MatchmakingQueue> // gameMode → queue
playerQueues: Map<string, string> // playerId → queueId (for fast lookup)Queue ID equals the gameMode string ('default', 'lite', 'singleplayer').
Match threshold: 2 players in the same queue triggers createMatchWithPlayers(). The first 2 players are spliced out, leaving any additional waiters in the queue.
Match ID Format
text
match_{Date.now()}_{randomString9chars}Example: match_1716825600000_a3f9z2k1x
XP System
See XP & Ranks for formulas, bonuses, and rank names.
Reward event types (stored in rewardEvents to prevent double-awarding):
- Match win → base XP + bonuses per band
- Match loss → base loss XP
XP config is stored in appConfig under key 'xp-config' and loaded into memory at startup. Changes via PUT /config/rewards take effect immediately.
Push Notifications
Push notifications fire when:
- A move is completed AND the next player is not the mover AND the next player is not in the foreground
Message format:
text
Title: "Your turn"
Body: "{moverName} played a band"
Data: { type: 'match_move', matchId }Player foreground state is tracked via appStateChange socket event from the client.
Environment Variables
| Variable | Used by | Purpose |
|---|---|---|
SPOTIFY_CLIENT_ID | SpotifyService | Spotify API credential |
SPOTIFY_CLIENT_SECRET | SpotifyService | Spotify API credential |
ADMIN_KEY | GameConfigController | Admin endpoint protection (x-admin-key header) |
See Environment Variables for the complete server env var list including optional logging flags and stale keys.
Existing Documentation
apps/server/docs/challenge-server.md— Socket events for challenge flow with full payloadsapps/server/docs/statistics-api.md— REST endpoints for statsapps/server/docs/logging.md— Logging setup and log levels