Skip to content

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 implementedpackages/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 historyBonus 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 recentBands query: fetch the authenticated player's last ~100 valid playBand moves from the bands table, 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:

  1. Search for the artist by name → get the Spotify artistId (GET /search?q={band}&type=artist&limit=1)
  2. Fetch their top tracks → GET /artists/{artistId}/top-tracks?market=from_token — returns up to 10 tracks, each with a preview_url (30 s MP3) and album.images
  3. Slice to the first 5 results, pick one at random, play preview_url via expo-av (already likely in the project) or expo-audio
  4. Use track.album.images[0].url as the visual — or fall back to GET /artists/{artistId} images[0].url for 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 artistId lookup 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_url is occasionally null for 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:

  • Adminnext lint + next build (Next's build catches type errors)
  • Client (RN)expo lint only; 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:

  1. Server-side setTimeout in startTurnTimer — schedules endGameByTimeout 500 ms after maxTurnTime on the server. The gateway subscribes via onGameEnded and broadcasts the result to the room, so the Matches page updates in real time even when the player isn't in the match screen.
  2. 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:

  1. Add Redis (presence + matchmaking plan)
  2. 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.x

New files:

FilePurpose
apps/server/src/game/timer.processor.tsBullMQ processor — receives expired job, calls endGameByTimeout, notifies gateway
apps/server/src/game/timer.queue.tsQueue name constant + job data type ({ gameId, timedOutPlayerId })

Files to modify:

FileChange
apps/server/src/game/game.module.tsRegister BullModule.registerQueue({ name: TIMER_QUEUE })
apps/server/src/app.module.tsImport BullModule.forRoot({ connection: { url: REDIS_URL } })
apps/server/src/game/game.service.tsReplace 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 room

Using 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 gameId

Why this is attractive for this specific problem:

  • Patching the deadline (e.g. addTime powerup) is a single atomic overwrite. ZADD on 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, where addTime updated maxTurnTime in the DB but left the original setTimeout armed 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 + maxTurnTime in the match doc) and subtract now. 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-safeZRANGEBYSCORE ... 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.x

New files:

FilePurpose
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.tsCustom IoAdapter — attaches Socket.IO Redis adapter via pub/sub clients

Files to modify:

FileChange
apps/server/src/main.tsInstantiate RedisIoAdapter, await adapter.connectToRedis(), app.useWebSocketAdapter(adapter)
apps/server/src/app.module.tsImport RedisModule
apps/server/src/presence/presence.service.tsFull rewrite — see below
apps/server/src/matchmaking/matchmaking.service.tsFull rewrite — see below
apps/server/src/matchmaking/matchmaking.gateway.tsRemove local presence Maps; replace with PresenceService calls; server.to(socketId) instead of server.sockets.sockets.get()
apps/server/src/game/game.gateway.tsAdd await on now-async presenceService.isForeground()

PresenceService rewrite — what stays vs moves:

DataWhereKey patternTTL
socketId → userIdIn-memory Mapsession
userId → socketIdRedisuser:{userId}:socket24 h
userId → nameRedisuser:{userId}:name24 h
userId → foregroundRedisuser:{userId}:foreground5 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:

KeyTypeTTLPurpose
queue:{gameMode}Redis ListWaiting players (JSON-serialised)
player:{playerId}:queueRedis String10 minReverse 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:

  • socketToUserId stays 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.emitToUser return type changes from boolean to Promise<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:

  1. pnpm --filter @band-game/server build — must compile clean
  2. Start server with REDIS_URL=redis://localhost:6379 — two clients identify + joinQueue → match created
  3. Restart server — reconnecting clients find no ghost queue entries (TTL eviction)
  4. (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.