Underground 2 Revived
Play Need for Speed: Underground 2 (2004) in Chrome and Firefox with WebGPU graphics, Web Audio, up to 120 fps widescreen, controller support, free roam and Online races with friends. Fan project, not
Hear one-tenth of a second of a song. Name the track. A real-time music guessing game you can play on your own playlists, from Deezer, Spotify, Apple Music or YouTube Music.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Hear 0.1 seconds of a song. Name the track. How fast can you guess?
A full-stack, real-time music guessing game.
Play your own playlists, chase a daily streak, run an endless speed-run, or race friends live.
Play it at sopranino.app - no account needed.
• What is Sopranino?
• Game modes
• Tech stack
• Architecture
• Technical highlights
• Getting started
• Project structure
• Development workflow
• Testing & CI
• Deployment
• Roadmap / What I'd add nextREADME 전문 보기 ↗Sopranino is a music guessing game. The core loop: you hear a snippet that starts at 0.1 seconds and grows with every wrong guess or skip - up to 12 seconds across 6 rounds. Nail the track early for more points.
The music comes from Deezer: a curated pool of ~6,000 tracks, every preview, the genre labels, and weekly country charts. Spotify links your own library - your playlists, your Liked Songs, and the search behind the guess box. You do not need either account to play.
Since 2.0, your own music no longer depends on linking an account: paste a link to a public Deezer playlist and its songs become a private set only you can play. Playlists on Spotify, Apple Music and YouTube Music get there by being copied to Deezer first, with a free transfer tool, and the app walks you through it step by step.
It started as a random idea me and a friend had while playing Songless and Bandle and ended up as an application with four distinct game modes, a real-time multiplayer layer, a daily streak system and much more...
AsyncLocalStorage.reducedMotion honored, image-derived ambient glows, and progressive album-art preloading so wins feel instant.Pick any playlist from your Spotify library (or your Liked Songs), or import one by pasting a public Deezer playlist link - including a Spotify, Apple Music or YouTube Music playlist copied over to Deezer. No account at all? Play a curated set instead - by decade, by genre, or one of the weekly country charts - all drawn from the Deezer pool. The game picks a random track and plays progressively longer snippets across 6 rounds.
| Round | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| Duration | 0.1s | 1s | 2s | 4s | 7s | 12s |
Wrong guesses are evaluated for partial matches - if you guessed the right artist, the right album, or both, the game tells you so. Round durations are driven by the backend so the client can't cheat.
One song per day, pulled from a set of playlists you configure (defaults to your Liked Songs). Win to extend your streak. Miss a day, streak resets - unless you have a streak freeze, earned by answering music-trivia quizzes. Streak math runs in the user's own IANA timezone, not the server's, so midnight actually means midnight where you are.
Endless, one-snippet-per-track. Pick a difficulty (5s Easy → 1s Expert), then see how long you can survive. One wrong guess or skip ends the run. Personal-best and global leaderboard included.
Create a room, share the invite code, and race your friends live. The host picks the round count and playlist; a shared track pool is built once and streamed to all clients. Every player's per-round status updates for everyone else in real time, and the final leaderboard lands the instant the last player finishes.
| Area | Choice | Why |
|---|---|---|
| Framework | Next.js 16 (App Router) + React 19 | Server components + RSC streaming, file-based routing, first-class TypeScript |
| Language | TypeScript 5 | Shared types end-to-end via generated SDK |
| Server state | TanStack Query 5 | Caching, optimistic updates, centralized query-key factory |
| Real-time | Socket.io client 4 | Room-based events, automatic reconnection, cookie-authed handshake |
| Styling | Tailwind CSS 3 + shadcn/ui + Radix UI | Design tokens via CSS vars, accessible primitives |
| Animations | Framer Motion 12 | Spring physics, AnimatePresence, user-preference-aware reduced motion |
| Audio | Web Audio API + <audio> | AnalyserNode-driven amplitude visualization, iOS warmup, lock-screen MediaSession controls |
| Forms/search | CMDK command palette, debounced track search | Forgiving fuzzy search against Spotify's catalog |
| Color theming | fast-average-color | Dominant-color extraction from album art for ambient UI glows |
| Observability | Vercel Analytics | Core Web Vitals & traffic |
| Testing | Jest 29 + ts-jest | Unit tests for hooks and utilities |
| Area | Choice | Why |
|---|---|---|
| Framework | NestJS 10 on Express | Module system, DI, decorators, Swagger integration |
| Language | TypeScript 5 with strict mode | |
| ORM | Prisma 7 | Type-safe queries, migrations, studio for local inspection |
| Database | PostgreSQL 16 | Relational model for users, games, rooms, stats |
| Cache / session store | Redis 7 (ioredis) | Sessions, PKCE state, rate-limit buckets, access-token cache |
| Queues | BullMQ 5 | Cron & delayed jobs: abandoned-game sweep, daily track, weekly chart refresh |
| Real-time | @nestjs/websockets + Socket.io 4 | Gateway pattern, room broadcasts, session-validated handshake |
| Validation | class-validator + class-transformer | DTO validation via pipes |
| Rate limiting | @nestjs/throttler + Redis storage | Session-scoped, not just IP-based |
| API docs | @nestjs/swagger + OpenAPI Generator | Single source of truth for frontend SDK |
| Music data | Deezer API | Track pool, previews, genre labels, weekly country charts |
| Resend | Verification and password-reset mail, DKIM/SPF/DMARC signed | |
| Media | Cloudinary | Avatar uploads |
| Auth | Spotify OAuth (PKCE) | No client secret, refresh token encrypted at rest |
| Testing | Jest 29 + @nestjs/testing | Service & controller unit tests |
| Logging | Custom AppLoggerService | Child loggers, contextual tagging |
no-floating-promises enforced, Prettier 3 with CI format-check┌───────────────────────────────────────────────────────────────┐
│ Frontend │
│ Next.js 16 · React 19 · TanStack Query · Framer Motion │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ ┌───────┐ │
│ │ Playlist │ │ Game │ │ Daily │ │ Speed │ │ MP │ │
│ │ Browser │ │ Engine │ │ Mystery │ │ Run │ │ Lobby │ │
│ └──────────┘ └──────────┘ └──────────┘ └─────────┘ └───────┘ │
│ ▲ ▲ ▲ ▲ ▲ │
└───────┼─────────────┼────────────┼────────────┼─────────┼─────┘
│ REST / OpenAPI SDK │ │ Socket.io │
▼ ▼ ▼ ▼ ▼ │
┌───────────────────────────────────────────────────────────────┐
│ Backend │
│ NestJS 10 · Prisma · BullMQ · Socket.io Gateway │
│ ┌──────┐ ┌──────┐ ┌────────┐ ┌────────┐ ┌──────────────────┐ │
│ │ Auth │ │ Game │ │Playlist│ │ Streak │ │ Multiplayer │ │
│ │ PKCE │ │ Stats│ │ Liked │ │ + Quiz │ │ Rooms · Gauntlet │ │
│ └──┬───┘ └──┬───┘ └───┬────┘ └───┬────┘ └────────┬─────────┘ │
│ │ │ │ │ │ │
│ ▼ ▼ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ PostgreSQL 16 (users · games · rooms · stats · tracks) │ │
│ │ Redis 7 (sessions · PKCE · throttle · access tokens) │ │
│ │ BullMQ queues (abandoned-game cleanup · imports · cron)│ │
│ │ Cloudinary (avatars) · Spotify Web API (tracks) │ │
│ │ Deezer public API (pool · charts · playlist imports) │ │
│ └──────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────┘
The frontend never talks to Spotify directly - all Spotify calls go through the backend so access tokens and refresh tokens stay server-side. The frontend consumes a TypeScript SDK auto-generated from the backend's OpenAPI spec, so every DTO, every enum, every query parameter is typed.
The backend's scripts/generate-openapi.ts boots a NestJS app, extracts the Swagger spec, and writes swagger-spec.json. openapi-generator-cli then emits a typescript-fetch SDK into frontend/sdk/. A single pnpm build:sdk regenerates both.
Result: every frontend call is typed against the actual backend controller - if I rename a DTO field, tsc --noEmit fails in CI on the frontend until I re-run codegen.
AuthService.startLogin() generates a UUID state + code verifier + SHA-256 code challenge, stores the PKCE state in Redis with a 10-minute TTL.handleCallback() atomically reads-and-deletes the PKCE entry (single-use), exchanges the code, and encrypts the returned refresh token with AES-256-GCM (32-byte hex key from env) before writing it to Postgres.httpOnly, sameSite=lax, secure-in-prod cookies. SessionGuard validates presence and existence on every protected route.Out-of-the-box @nestjs/throttler limits by IP, which is wrong for a shared-WiFi world. SessionThrottlerGuard keys off the session cookie (falling back to IP if there isn't one) and stores counters in Redis. The login/gate endpoints use stricter buckets to deter brute force.
Earlier, any authenticated user could hit /game/{id}/guess with another user's game ID and the backend would happily accept it. PR #50 (ownership checks to game state and guess endpoints) closed that - every GameService method now joins on userId and throws ForbiddenException when there's a mismatch.
StreakService never uses new Date()-style "server time." It accepts the user's IANA timezone (from UserPreference.timezone, auto-detected client-side and confirmed on the preferences page) and computes "today" via date-fns-tz. Streak freezes are first-class: if a gap day is found, consumed freezes are recorded in a StreakFreezeUsage audit table so the UI can explain exactly which day was saved.
RoomsGateway parses the unpaused_session cookie on handleConnection, validates it against Redis, and rejects the socket if the session is invalid - no anonymous sockets. Presence is tracked in-memory per room (Map<roomId, Set<userId>>).
When the host disconnects, the gateway doesn't immediately tear down the room - it waits ~3–5s (debounced) to distinguish a tab refresh from an actual leave. This one detail eliminates 90% of the "my host flickered and the room died" bugs.
Round state is stored in Postgres via MultiplayerGameService; WebSocket events simply invalidate TanStack Query caches on the clients, which refetch the authoritative state. The server is the single source of truth; the clients are just projections.
A BullMQ queue (GAME_CLEANUP_QUEUE) runs a cron every 6 hours to mark game sessions that have been idle past a threshold as ABANDONED, so they stop hanging around in active-game queries and don't count against stats. Exponential-backoff retry is wired in via a shared JOB_OPTIONS_WITH_BACKOFF.
@Transactional decorator with AsyncLocalStorageInstead of threading a Prisma client through every service method, a custom @Transactional() decorator uses Node's AsyncLocalStorage to transparently propagate the active transaction. Every repository call resolves the Prisma client via a proxy - inside a transactional context it returns the tx, outside it returns the root client. Service code stays clean; ACID boundaries are explicit at the decorator.
The play button pulses in sync with the music. Done naïvely that's a setState every animation frame - ~60 React renders per second of playback. Instead, useAudioAmplitude wires an AnalyserNode into a Framer Motion MotionValue<number>; the button subscribes to the motion value and updates CSS directly via the Motion runtime. React never re-renders, but the UI is perfectly reactive.
Safari blocks audio.play() outside a user gesture and tears down the audio context when the tab backgrounds. useGameAudio keeps a warmup audio element, re-primes the context on visibilitychange, uses monotonic "play request" IDs to discard stale callbacks, and exposes a MediaSession handler so lock-screen play/pause doesn't trigger snippet playback mid-round.
Submitting a guess optimistically adds it to the guesses list so the UI feels instant. Early on, a race condition would briefly render the guess as "Wrong" before the server's actual Correct/Artist/Album result arrived (PR #62). The fix: the optimistic guess carries a pending flag that suppresses the color-coded result row until the real mutation settles. Small, but a huge feel improvement.
Reading somebody's playlist from a link needs Spotify's extended quota, which since 2025 is granted only to registered organizations with 250k monthly active users. That is not a bar a side project clears, so 2.0 takes the route that is open: Deezer's public API, which serves a playlist, its tracks, their ISRCs and 30-second previews without a token at all.
Paste a public Deezer playlist link and the backend resolves it (including link.deezer.com short links), creates a private TrackGroup of type IMPORTED, and fills it on a BullMQ queue. What that buys:
Any service works, in fact, not only the ones with a pill: the mechanism is a Deezer link, so a playlist from Amazon Music or Tidal can be copied over by hand and pasted in. What is lost is only the label - the card says Deezer rather than where it started.
A playlist from any other service arrives the same way, via a copy on Deezer made with a free transfer tool. The player says where it came from, and one guide with screenshots walks the detour rather than pretending it is not there. Where the copy came from is recorded on the player's own membership row, so the card can say "Spotify playlist, copied to Deezer" for one player and plain Deezer for another.
Spotify removed anonymous preview URLs from the public API in early 2026 (PR #79). PreviewLookupService resolves them from open, unauthenticated catalogues instead, in order: Deezer by ISRC when one is known (an exact match), then the iTunes Search API, then Deezer search. Matching compares artist and title with Spotify's trailing qualifiers stripped, so - Mono and (Remastered) don't cause a miss. Results are cached in Redis for 7 days (1 day for a miss) and stored on the Track row.
On a 45-track sample spanning the 1960s to the 2020s, iTunes and Deezer each resolved 45/45. The old web-player scraper (PreviewScraperService) remains as a last resort and logs whenever it is reached, so it can be retired on evidence.
npm install -g pnpm)git clone https://github.com/carlosverissimo3001/sopranino.git
cd sopranino
pnpm install
docker compose up -d
This brings up Postgres 16 on :5432 and Redis 7 on :6379, both with healthchecks and persistent volumes.
http://localhost:3001/auth/callbackStart from the template, then fill in your Spotify client ID and generate the encryption key:
cp backend/.env.example backend/.env
backend/.env
DATABASE_URL="postgresql://unpaused:unpaused_dev@localhost:5432/unpaused?schema=public"
REDIS_URL="redis://localhost:6379"
SPOTIFY_CLIENT_ID="your_client_id"
SPOTIFY_REDIRECT_URI="http://localhost:3001/auth/callback"
SESSION_MAX_AGE_SECONDS="604800"
TOKEN_ENCRYPTION_KEY="64-character-hex-string" # openssl rand -hex 32
FRONTEND_URL="http://localhost:3000"
# Optional
LAST_FM_API_KEY="..."
CLOUDINARY_URL="cloudinary://..."
RESEND_API_KEY="re_..." # unset, and mail goes to the log
EMAIL_FROM="sopranino <[email protected]>"
EMAIL_REPLY_TO="..." # an inbox someone actually reads
PORTFOLIO_URL="..." # extra CORS origin for the demo
# Production only: lets one cookie cover the site and the API subdomain.
# Leave unset locally, where localhost and 127.0.0.1 share no parent domain.
COOKIE_DOMAIN=""
frontend/.env.local
NEXT_PUBLIC_APP_URL="http://localhost:3000"
NEXT_PUBLIC_API_URL="http://localhost:3001"
# Optional site-wide password gate
SITE_PASSWORD="..."
pnpm db:generate
pnpm db:migrate
pnpm dev
pnpm db:studiosopranino/
├── backend/
│ ├── prisma/
│ │ └── schema.prisma # Users, GameSessions, Tracks, Stats, MultiplayerRooms, GauntletRuns, UserPreferences…
│ ├── scripts/
│ │ └── generate-openapi.ts # Emits swagger-spec.json for SDK generation
│ └── src/
│ ├── admin/ # Admin dashboard: user roles, streak question CRUD
│ ├── auth/ # Spotify OAuth (PKCE), session service, token encryption
│ ├── game/ # Core single-player engine, guess evaluation, stats
│ ├── gauntlet/ # Speed-run / endless mode, difficulty tiers, leaderboard
│ ├── multiplayer/ # Rooms gateway (WebSocket), room service, track pool
│ ├── playlist/ # Spotify playlist + Liked Songs integration
│ ├── spotify/ # Spotify Web API wrapper with rate-limit handling
│ ├── streak/ # Daily streak + freeze logic, trivia quiz service
│ ├── track/ # Track catalog, preview URL lookup (iTunes/Deezer), Last.fm enrichment
│ ├── transaction/ # @Transactional decorator + AsyncLocalStorage
│ ├── throttle/ # SessionThrottlerGuard (Redis-backed)
│ ├── user-avatar/ # Cloudinary uploads
│ ├── user-preferences/ # Timezone, hints, theme, daily playlist selection
│ ├── redis/ # ioredis module
│ ├── prisma/ # Prisma module with tx-aware proxy
│ ├── logger/ # AppLoggerService
│ └── utils/ # Guards (Session / Admin / Trusted), decorators (@SessionId), normalization helpers
├── frontend/
│ ├── app/ # Next.js App Router
│ │ ├── page.tsx # Home (game modes + playlist grid)
│ │ ├── game/[playlistId]/ # Single-player game
│ │ ├── daily/ # Daily Mystery + daily stats
│ │ ├── speed-run/ # Gauntlet mode + leaderboard
│ │ ├── multiplayer/ # Lobby, room, play, results
│ │ ├── history/ # All-time game history vault
│ │ ├── preferences/ # User settings
│ │ ├── admin/ # Admin dashboard
│ │ ├── gate/ # Optional site-wide password gate
│ │ └── api/auth/gate/ # Rate-limited gate route handler
│ ├── components/
│ │ ├── game/ # Game loop UI, volume, reveal card, guess list
│ │ ├── multiplayer/ # Lobby, player rows, invite card, round dots
│ │ ├── daily/ # Streak badge, countdown, score distribution, share pattern
│ │ ├── speed-run/ # Rapid-fire play screen, leaderboard
│ │ ├── streak/ # Freeze prompt, trivia quiz
│ │ ├── history/ # Filterable, paginated history views
│ │ ├── playlist/ # Grid, filters, cards
│ │ ├── features/ # Game mode gallery, app header/footer
│ │ ├── providers/ # Query, Theme, Motion providers
│ │ └── ui/ # shadcn/ui primitives + custom (ErrorBanner, OfflineBanner, Spinner)
│ ├── hooks/ # Domain-scoped React hooks (game, multiplayer, spotify, streak, speed-run, admin, auth)
│ ├── sdk/ # 🔒 auto-generated from swagger-spec.json - do not edit
│ └── lib/ # queryKeys factory, styles, utils
├── docker-compose.yml # Local Postgres + Redis
├── swagger-spec.json # Generated OpenAPI spec
└── pnpm-workspace.yaml
| Command | What it does |
|---|---|
pnpm dev | Runs frontend + backend concurrently |
pnpm dev:frontend / pnpm dev:backend | Run just one side |
pnpm build | Production build of both workspaces |
pnpm build:sdk | Regenerate swagger-spec.json + frontend SDK |
pnpm db:generate | Regenerate Prisma client after schema changes |
pnpm db:migrate | Apply / create migrations |
pnpm db:studio | Open Prisma Studio against local Postgres |
pnpm test | Run all Jest tests (both workspaces) |
pnpm test:backend / pnpm test:frontend | Run one side |
Anytime you touch a backend DTO or controller signature, run pnpm build:sdk. CI runs the same step on PRs, so drift is caught automatically.
ts-jest on both workspaces; backend has a spec file colocated with most services (guess evaluator, throttler storage, streak math, etc.)..github/workflows/ci.yml) runs on every push to main and every PR to main/develop:
tsc --noEmit → Jest (with a test DATABASE_URL)tsc --noEmit → Jest@typescript-eslint/no-floating-promises: error and curly: [error, all] - both catches small async bugs that would otherwise slip through..prettierignore skips the generated SDK.sopranino.app. HTTP calls go to /api/* and are rewritten to the backend, so they stay same-origin. The websocket cannot use that path - it serves /socket.io - so it dials api.sopranino.app, and COOKIE_DOMAIN scopes the session cookie to cover both.api.sopranino.app. Postgres and Redis are provisioned add-ons; TOKEN_ENCRYPTION_KEY is an environment secret generated via openssl rand -hex 32.pnpm --filter backend prisma:migrate-prod (see scripts/ in backend/), kicked off on deploy.Some things that are scoped but not built (or only partially built):
This repository is published for portfolio purposes. The code is shared "as is" without warranty; please don't deploy a copy as a commercial service.
Music data is provided by Deezer. Spotify, Apple Music, YouTube Music, Deezer and their marks are trademarks of their respective owners: Spotify AB, Apple Inc., Google LLC and Deezer S.A. This project is affiliated with none of them.