Roadmap
Items are sorted by implementation horizon. "Later" items have full plans written — they're deferred by deliberate decision, not by lack of planning.
Now (In Progress)
Nothing in progress.
Next (Queued)
Shared Packages Architecture
Status: Partially implemented — packages/types created in the TypeScript Typing Improvements phase.
Consolidate all cross-app types into installable workspace packages so that a field rename is a compile error on every app simultaneously. See the full plan for all four packages, their contents, and implementation order.
What's done: packages/types (game domain types), packages/db-schema (Drizzle schema + inferred insert/select types)
What's deferred:
packages/api-contracts— socket event payload types (server ↔ client ↔ admin)packages/ui-types— display-layer view model types (client + admin)
Trigger: implement api-contracts and ui-types when starting a client typing pass.
Band Freshness Bonus (XP for variety)
Status: Early idea — numbers not finalised, no implementation started.
Problem being solved: Players can farm easy XP by replaying the same familiar bands across many matches. The existing discovery bonus (+5 XP) only rewards a band's first-ever play — it doesn't discourage leaning on a small personal repertoire match after match.
Idea: Track each player's last N played bands and use recency/frequency as an XP multiplier. The more recently you played a band, the less XP it's worth.
Proposed tiers (example numbers — to be tuned):
| Band rank in your recent history | Bonus XP |
|---|---|
| Top 10 most played | +0 |
| Top 11–25 | +5 |
| Top 26–50 | +10 |
| Top 51–100 | +15 |
| Outside top 100 | +20 |
Implementation sketch:
- Add a
recentBandsquery: fetch the authenticated player's last ~100 validplayBandmoves from thebandstable, grouped and counted by canonical band name - Rank by play count descending → assign tier → apply bonus at
fireMatchRewards()time alongside existing bonuses - The lookup doubles as the first layer of the planned three-tier validation pipeline:
recent 100 → full bands table → Spotify
Open questions:
- Window size: last 100 plays? Last 30 days? Both?
- Should the tier be based on play count (frequency) or recency (last played date)?
- Do singleplayer bands count toward the history?
Later (Planned, Deferred)
Tap Band Name to Play a Song Preview
Status: Early idea — no implementation started.
What it does: Tapping the band name inside a message bubble fetches that artist's top tracks from Spotify, picks one at random from the top five, and plays the 30-second preview clip. While the audio plays, the album cover (or the artist's Spotify image) is revealed inside the message bubble — masked to the bubble shape. When the preview ends (or the user taps again to dismiss), the image fades out and the bubble returns to its normal state.
Spotify API sketch:
- Search for the artist by name → get the Spotify
artistId(GET /search?q={band}&type=artist&limit=1) - Fetch their top tracks →
GET /artists/{artistId}/top-tracks?market=from_token— returns up to 10 tracks, each with apreview_url(30 s MP3) andalbum.images - Slice to the first 5 results, pick one at random, play
preview_urlviaexpo-av(already likely in the project) orexpo-audio - Use
track.album.images[0].urlas the visual — or fall back toGET /artists/{artistId}images[0].urlfor a band photo
UI behaviour:
- Tap band name → triggers fetch + playback; show a loading indicator if the request takes >300 ms
- While playing: album/artist image fades in, masked to the bubble shape; a subtle progress bar or waveform animation plays underneath
- On completion or second tap: image + audio fade out, bubble returns to normal
- Only one preview plays at a time — starting a new one stops the previous
Open questions:
- Do we cache the
artistIdlookup so repeated taps don't re-query Spotify search? - Should the preview be opt-in (toggle in settings) given it plays audio unexpectedly?
- Spotify
preview_urlis occasionallynullfor some tracks — need a fallback (skip to the next track in the top-5 list) - Do we show the album cover or the artist photo? Album cover is more visually varied; artist photo ties the image to the band
Dependencies: Spotify client credentials token (already used for band validation) + expo-av or expo-audio for playback.
Expand CI Coverage to All Apps
Status: Low priority — server CI is solid, wiki lint is in place.
The admin (@band-game/admin) and client (@band-game/client) apps are not yet covered by CI. The groundwork is done; adding them is straightforward when there's appetite for it.
What to add:
- Admin —
next lint+next build(Next's build catches type errors) - Client (RN) —
expo lintonly; native builds (expo run:ios/android) require macOS/Android runners and are expensive to run on every push
Trigger: when either app sees regular active development, or when a lint/type regression slips through unnoticed.
BullMQ-Backed Turn Timers
Status: Deferred — current setTimeout + DB-check fallback is adequate for a single instance; implement alongside or immediately after the Redis rollout.
Problem being solved: Turn timers are currently enforced by the client. When the countdown hits zero, the client sends clientTimeout and the server ends the game. This breaks silently when a player exits the match screen (timer clears on unmount, event never fires) or when the server restarts mid-turn (all in-process setTimeout handles are lost). The game gets stuck in "active" with an expired timer.
Current mitigations (in place as of May 2026):
Two fallbacks were added alongside the server-side setTimeout fix:
- Server-side
setTimeoutinstartTurnTimer— schedulesendGameByTimeout500 ms aftermaxTurnTimeon the server. The gateway subscribes viaonGameEndedand broadcasts the result to the room, so the Matches page updates in real time even when the player isn't in the match screen. - Expiry check in
getGameState— whenever the game state is fetched (e.g. on re-entering a match), any expired timer is finalized inline before the state is returned.
These handle the common cases but share the same weakness: both live in process memory. A server restart between the reveal and the timer firing still leaves the game unresolved until someone opens it.
Why BullMQ, not raw Redis polling:
The natural Redis solution — store { matchId, expiry } in a sorted set and poll with ZRANGEBYSCORE 0 <now> every second — is exactly what BullMQ does internally. Writing it from scratch means rebuilding the hard parts: atomic job claiming under concurrent workers (two instances can't both process the same job), retry logic, dead-letter queue for failed handlers, and clean cancellation when a game ends early. BullMQ is purpose-built for delayed jobs in Node.js and has first-class NestJS integration via @nestjs/bullmq.
How it fits the existing Redis plan:
This shares the same Redis instance from the Redis-Backed Presence & Matchmaking Queues plan — no additional infrastructure. The implementation order should be:
- Add Redis (presence + matchmaking plan)
- Add BullMQ timer jobs (this plan) — just add the queue module and processor
Packages to add (on top of the Redis plan):
text
bullmq ^5.x
@nestjs/bullmq ^10.xNew files:
| File | Purpose |
|---|---|
apps/server/src/game/timer.processor.ts | BullMQ processor — receives expired job, calls endGameByTimeout, notifies gateway |
apps/server/src/game/timer.queue.ts | Queue name constant + job data type ({ gameId, timedOutPlayerId }) |
Files to modify:
| File | Change |
|---|---|
apps/server/src/game/game.module.ts | Register BullModule.registerQueue({ name: TIMER_QUEUE }) |
apps/server/src/app.module.ts | Import BullModule.forRoot({ connection: { url: REDIS_URL } }) |
apps/server/src/game/game.service.ts | Replace setTimeout in startTurnTimer with this.timerQueue.add(...) with delay: maxTurnTime + 500; cancel job in endGameByTimeout and abandonGame via job.remove() |
Job lifecycle:
text
revealBand
→ startTurnTimer()
→ timerQueue.add('end-turn', { gameId, timedOutPlayerId }, { delay: maxTurnTime + 500, jobId: gameId })
client sends clientTimeout (or move with expired timer)
→ endGameByTimeout()
→ timerQueue.remove(gameId) ← cancels the server job so it doesn't double-fire
BullMQ fires (player never submitted)
→ TimerProcessor.process()
→ endGameByTimeout() with guard: if status !== 'active' → no-op
→ notifyGameEnded() → gateway broadcasts gameState + turnTimeout to roomUsing jobId: gameId means only one job per match can be queued at a time — starting a new turn automatically replaces the old job.
How Redis would control timer state (notes for implementation, June 2026)
Captured here so the approach isn't lost before the Redis rollout starts. The core idea: stop treating "the timer" as a scheduled callback (setTimeout/BullMQ delayed job) that has to be remembered, cancelled, and replaced whenever the deadline moves — instead treat it as a single piece of mutable state (a deadline timestamp) that's swept on a fixed interval.
Storage — a sorted set keyed by deadline:
text
ZADD active-timers <deadlineMs> <gameId>The score is the absolute deadline (turnStartedAt + maxTurnTime, in epoch ms), the member is the gameId. This is the same { matchId, expiry } shape already described above — but instead of feeding it into a delayed-job queue, a lightweight interval just polls it directly:
text
every 1s:
expired = ZRANGEBYSCORE active-timers 0 <now>
for gameId in expired:
endGameByTimeout(gameId)
ZREM active-timers gameIdWhy this is attractive for this specific problem:
- Patching the deadline (e.g.
addTimepowerup) is a single atomic overwrite.ZADDon an existing member replaces its score — no cancel-then-reschedule choreography, no risk of an old callback firing against a stale duration (the kind of bug seen in BAN-72, whereaddTimeupdatedmaxTurnTimein the DB but left the originalsetTimeoutarmed against the old deadline). Extending a turn becomes exactly the same operation as starting one:ZADD active-timers <newDeadlineMs> <gameId>. - The deadline is always a persisted, queryable value, not a transient in-process handle. A rejoining client (BAN-35), a restarted server, or a second instance can all recompute "how much time is left" the same way: read the score (or the mirrored
currentTimer.turnStartedAt+maxTurnTimein the match doc) and subtractnow. Nothing needs to "resend" a timer-started event for the state to be reconstructable. - Ending a turn early (player moves before timeout) is also a single op:
ZREM active-timers gameId. - Sweeping is cheap and naturally cross-instance-safe —
ZRANGEBYSCORE ... 0 <now>is O(log N + M), and if multiple instances run the sweep, wrapping the per-game handling in a short Redis lock (SET lock:{gameId} NX PX 5000) makes it safe for only one instance to act on a given expiry.
Relationship to the BullMQ plan above: BullMQ remains the better choice if we want retries, dead-letter handling, and distributed job-claiming guarantees out of the box (see "Why BullMQ, not raw Redis polling" above — this is the raw-polling approach that section argues BullMQ already implements internally). The sorted-set approach is the simpler fallback worth keeping in mind if BullMQ ever feels like overkill for what is fundamentally "one timestamp per active game, checked once a second" — and either way, Redis as first source of truth for the deadline, DB as backup, with patching expressed as a plain overwrite, is the model to build toward.
Future: Redis as general game-state cache
Once Redis is in the stack, GameStateCacheService (currently an in-memory Map) can be replaced with a Redis-backed cache. This removes the last in-process state. The same RedisService from the presence plan is reused; the cache layer gets TTL-based eviction for free.
Trigger: implement when the Redis plan is triggered, or sooner if a server restart causing unresolved matches becomes a real complaint from testers.
Redis-Backed Presence & Matchmaking Queues
Status: Deferred — plan complete, implementation not yet needed at current scale.
Context: As of May 2026 the server runs as a single Railway instance with two developers and a small beta incoming. Queue waits are seconds, losing queued players on a deploy is a non-event, and sticky sessions on a single instance handle everything correctly.
Trigger conditions — implement when any of these is hit:
- Queue wait times exceed ~30 s consistently
- A zero-downtime rolling deploy is required (players can't tolerate a restart mid-session)
- A second Railway instance is provisioned
Problem being solved: All matchmaking queue state and player presence live in process memory. A server restart wipes every waiting player. Running more than one instance requires sticky sessions (fragile). Redis fixes both.
Packages to add:
text
ioredis ^5.4.x
@socket.io/redis-adapter ^8.3.xNew files:
| File | Purpose |
|---|---|
apps/server/src/redis/redis.service.ts | @Global() injectable wrapping a single ioredis client from REDIS_URL |
apps/server/src/redis/redis.module.ts | @Global() NestJS module — import once in AppModule |
apps/server/src/redis-io.adapter.ts | Custom IoAdapter — attaches Socket.IO Redis adapter via pub/sub clients |
Files to modify:
| File | Change |
|---|---|
apps/server/src/main.ts | Instantiate RedisIoAdapter, await adapter.connectToRedis(), app.useWebSocketAdapter(adapter) |
apps/server/src/app.module.ts | Import RedisModule |
apps/server/src/presence/presence.service.ts | Full rewrite — see below |
apps/server/src/matchmaking/matchmaking.service.ts | Full rewrite — see below |
apps/server/src/matchmaking/matchmaking.gateway.ts | Remove local presence Maps; replace with PresenceService calls; server.to(socketId) instead of server.sockets.sockets.get() |
apps/server/src/game/game.gateway.ts | Add await on now-async presenceService.isForeground() |
PresenceService rewrite — what stays vs moves:
| Data | Where | Key pattern | TTL |
|---|---|---|---|
socketId → userId | In-memory Map | — | session |
userId → socketId | Redis | user:{userId}:socket | 24 h |
userId → name | Redis | user:{userId}:name | 24 h |
userId → foreground | Redis | user:{userId}:foreground | 5 min (refreshed on appStateChange active) |
Methods that become async: set(), setForeground(), isForeground(), remove(), getSocketId() (new), getName() (new). getUserId(socketId) stays sync (local Map).
MatchmakingService rewrite — Redis keys:
| Key | Type | TTL | Purpose |
|---|---|---|---|
queue:{gameMode} | Redis List | — | Waiting players (JSON-serialised) |
player:{playerId}:queue | Redis String | 10 min | Reverse index: which queue a player is in |
Atomic match pop uses a Lua script to prevent duplicate match creation under concurrent joins:
lua
local key = KEYS[1]
if redis.call('LLEN', key) >= 2 then
return {redis.call('LPOP', key), redis.call('LPOP', key)}
end
return {}Key design decisions:
socketToUserIdstays in-memory — socket objects are local to the instance; Redis only needed for the reverse lookup- Socket.IO Redis adapter handles room broadcasts (
server.to(matchId).emit()calls throughout require zero changes) - TTLs auto-evict stale players from the queue (10 min), preventing ghost entries after restarts
IUserNotificationService.emitToUserreturn type changes frombooleantoPromise<boolean>
Railway setup: Add Redis plugin in the Railway dashboard → link to the server service → REDIS_URL is auto-injected. No code change needed for the env var.
Verification checklist:
pnpm --filter @band-game/server build— must compile clean- Start server with
REDIS_URL=redis://localhost:6379— two clientsidentify+joinQueue→ match created - Restart server — reconnecting clients find no ghost queue entries (TTL eviction)
- (Future) Two server instances on same Redis — cross-instance challenge delivery and game broadcasts work
Done
Database Service Refactor
Shipped — May 2026
Split the ~1020-line DatabaseService god class into eight focused @Injectable() repositories (MatchRepository, BandRepository, FriendInvitationRepository, FriendRepository, ChallengeRepository, PushTokenRepository, AppConfigRepository, XpRepository) each owning its own Drizzle queries and row mappers. A new DrizzleService holds the shared connection. All consumers inject specific repositories directly; DatabaseService is deleted entirely. The /db-stats operation-counter endpoints were removed alongside it.
TypeScript Typing Improvements
Shipped — May 2026
Restored strict no-unsafe-* ESLint rules to error. Created packages/types (@band-game/types) with all core game domain types, and packages/db-schema (@band-game/db-schema) with Drizzle schema + inferred insert/select types. Eliminated all as any casts in database.service.ts, game.service.ts, game-state-cache.service.ts, game.gateway.ts, and match-start.service.ts. Three intentional no-unsafe-argument warnings remain (the data: any parameter boundary in updateDocument and one in game.service.ts) — these stay as warn. See the full plan for details.