chore: initial scaffold (Turborepo + Vite/React + shadcn)
This commit is contained in:
parent
e7b2f46e81
commit
ad13eb4e68
2 changed files with 418 additions and 0 deletions
333
ARCHITECTURE.md
Normal file
333
ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
# NerdWare — Architecture
|
||||
|
||||
> Party game web multi-épreuves centré culture geek (jeux vidéo, manga, pop culture), ambiance WarioWare (rythme rapide, fun, transitions punchy).
|
||||
> Packages du monorepo : `@nerdware/shared`, `@nerdware/server`, `@nerdware/client`.
|
||||
> V1 : **Blindtest (YouTube)** + **Quiz culture**. Pensé pour s'étendre (GeoGuessr-like, etc.).
|
||||
|
||||
---
|
||||
|
||||
## 1. Principes directeurs
|
||||
|
||||
- **Web app, chacun sur son PC.** Pas d'écran central / pas d'hôte spécial pour l'affichage. Chaque joueur voit tout (titre en cours, choix, scoreboard) synchronisé en temps réel.
|
||||
- **Serveur source de vérité.** Le timer, les scores, l'ordre des manches, le tirage du DJ : tout est tranché côté serveur. Les clients n'affichent qu'un état reçu. Jamais confiance au client (triche / désync).
|
||||
- **Moteur de jeu générique.** Chaque épreuve implémente la même interface (`start / submitAnswer / reveal / score`). Brancher une nouvelle épreuve = écrire un module, pas retoucher la plomberie.
|
||||
- **Audio joué localement par chaque client**, mais **piloté centralement** (le DJ broadcast play/pause/seek, les autres se recalent).
|
||||
|
||||
---
|
||||
|
||||
## 2. Stack technique
|
||||
|
||||
| Couche | Choix | Notes |
|
||||
|---|---|---|
|
||||
| Langage | **TypeScript** (front + back) | Types partagés des events WS → désync = erreur de compile |
|
||||
| Backend HTTP | **Fastify** | OAuth YouTube, back-office quiz, health checks |
|
||||
| Temps réel | **Socket.IO** | Cœur du système : rooms natives, reconnexion auto, acks |
|
||||
| Game loop | Node + autorité serveur | Timer, scores, tirage DJ tranchés serveur |
|
||||
| État des rooms | En mémoire (Map) | Suffisant pour jouer entre potes |
|
||||
| Persistance contenu | **PostgreSQL** (instance partagée) + **Drizzle** ORM | Questions de quiz, catégories, historiques |
|
||||
| Audio | **YouTube IFrame Player API** | Gratuit, pas de Premium requis |
|
||||
| Front build | **Vite + React** | SPA, pas de SSR (jeu derrière un lobby) |
|
||||
| Routing | **wouter** | ~2 ko ; 3-4 routes triviales, pas besoin de plus |
|
||||
| État client | **Zustand** | Reflète l'état poussé par Socket.IO |
|
||||
| Données HTTP | **TanStack Query** | UNIQUEMENT back-office quiz + auth (pas le jeu temps réel) |
|
||||
| UI | **Tailwind + shadcn/ui** | Contrôle total du style pour l'ambiance WarioWare |
|
||||
| Animations | **Framer Motion** | Transitions de manche, reveal, scores, countdown — le "fun" |
|
||||
| (Optionnel) | Redis | Seulement pour survivre à un redémarrage serveur |
|
||||
|
||||
### Pourquoi YouTube plutôt que Spotify
|
||||
- Pas de compte Premium requis ; lecture dans n'importe quel navigateur.
|
||||
- Synchro gérable via `seekTo(seconds)` + events de buffering.
|
||||
- Recherche/soumission par URL ou API Data v3.
|
||||
- **Export Spotify en bonus post-game** : on stocke titre + artiste, et en fin de partie on génère des liens de recherche / une playlist.
|
||||
- ⚠️ Piège : certaines vidéos ont l'**embedding désactivé** → détecter à la soumission, prévoir un fallback.
|
||||
|
||||
---
|
||||
|
||||
## 3. Schéma de données
|
||||
|
||||
### Persistant (PostgreSQL)
|
||||
|
||||
```
|
||||
quiz_category
|
||||
id uuid pk
|
||||
name text
|
||||
created_at timestamptz
|
||||
|
||||
quiz_question
|
||||
id uuid pk
|
||||
category_id uuid fk -> quiz_category
|
||||
format text -- 'mcq' | 'truefalse' | 'free' | 'image_reveal'
|
||||
prompt text
|
||||
difficulty int -- 1..3
|
||||
source text -- 'opentdb' | 'manual'
|
||||
-- champs selon le format (NULL si non applicable) :
|
||||
choices jsonb -- mcq/truefalse : ["A","B","C","D"]
|
||||
correct_index int -- mcq/truefalse : index de la bonne réponse
|
||||
accepted_answers jsonb -- free : ["Jupiter","planète Jupiter", ...] (matching tolérant)
|
||||
image_url text -- image_reveal : image à dévoiler
|
||||
created_at timestamptz
|
||||
|
||||
quiz_played -- anti-répétition : ce qui a déjà été joué
|
||||
question_id uuid fk -> quiz_question
|
||||
room_code text -- ou un identifiant de groupe de potes
|
||||
played_at timestamptz
|
||||
|
||||
game_history -- optionnel, pour stats / portfolio
|
||||
id uuid pk
|
||||
room_code text
|
||||
played_at timestamptz
|
||||
modes jsonb -- épreuves jouées
|
||||
results jsonb -- scores finaux par joueur
|
||||
```
|
||||
|
||||
> Le blindtest n'a **pas** besoin de DB : les titres sont soumis en session, vivent le temps de la partie.
|
||||
|
||||
### Éphémère (en mémoire, par room)
|
||||
|
||||
```
|
||||
Room
|
||||
code string -- code de jointure (ex: "RTKP")
|
||||
status 'lobby' | 'in_round' | 'reveal' | 'scores' | 'ended'
|
||||
players Map<playerId, Player>
|
||||
settings RoomSettings
|
||||
rounds Round[] -- planifiés
|
||||
currentRound int
|
||||
scores Map<playerId, number>
|
||||
|
||||
Player
|
||||
id string
|
||||
name string
|
||||
connected bool
|
||||
socketId string
|
||||
|
||||
RoomSettings
|
||||
blindtestMode 'title_artist' | 'who_added' | 'mixed'
|
||||
roundDuration int -- secondes (def. 60)
|
||||
rounds RoundConfig[] -- séquence d'épreuves
|
||||
|
||||
Round (état runtime)
|
||||
type 'blindtest' | 'quiz'
|
||||
djId string | null -- blindtest only, joueur neutre
|
||||
payload object -- dépend du type (voir §5)
|
||||
votes Map<playerId, Vote>
|
||||
startedAt number -- timestamp serveur
|
||||
endsAt number -- startedAt + duration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Interface du game loop générique
|
||||
|
||||
Chaque épreuve implémente ce contrat. Le moteur ne connaît que cette interface.
|
||||
|
||||
```ts
|
||||
interface GameRound {
|
||||
type: string;
|
||||
|
||||
// Prépare la manche : tire le DJ si besoin, choisit le contenu,
|
||||
// renvoie le payload à broadcaster (sans la réponse !).
|
||||
start(room: Room): RoundStartPayload;
|
||||
|
||||
// Enregistre/valide le vote d'un joueur. Idempotent.
|
||||
submitAnswer(room: Room, playerId: string, answer: Answer): void;
|
||||
|
||||
// Calcule la bonne réponse + détail à révéler à tous.
|
||||
reveal(room: Room): RevealPayload;
|
||||
|
||||
// Met à jour room.scores selon les votes vs la vérité.
|
||||
score(room: Room): ScoreDelta[];
|
||||
}
|
||||
```
|
||||
|
||||
**Boucle d'orchestration (serveur) :**
|
||||
|
||||
```
|
||||
lobby
|
||||
→ pour chaque RoundConfig dans settings.rounds :
|
||||
round.start() → broadcast ROUND_START (payload sans réponse)
|
||||
[timer serveur lancé]
|
||||
... submitAnswer() au fil des votes ...
|
||||
fin = (timer écoulé) OU (tous ont voté)
|
||||
round.reveal() → broadcast ROUND_REVEAL
|
||||
round.score() → broadcast SCORE_UPDATE
|
||||
→ broadcast GAME_END (scores finaux + export Spotify éventuel)
|
||||
```
|
||||
|
||||
**Règle de fin de manche :** la première des deux conditions — `endsAt` atteint **ou** tous les joueurs éligibles ont voté — déclenche le reveal. Le DJ, s'il y en a un, compte comme votant éligible.
|
||||
|
||||
---
|
||||
|
||||
## 5. Events WebSocket
|
||||
|
||||
> Convention : `CLIENT → SERVER` et `SERVER → CLIENT`. Tout timestamp est **serveur**.
|
||||
|
||||
### Connexion / lobby
|
||||
|
||||
| Event | Sens | Payload |
|
||||
|---|---|---|
|
||||
| `room:create` | C→S | `{ playerName }` → renvoie `{ roomCode, playerId }` |
|
||||
| `room:join` | C→S | `{ roomCode, playerName }` |
|
||||
| `room:state` | S→C | snapshot complet de la room (à chaque changement) |
|
||||
| `lobby:updateSettings` | C→S | `{ blindtestMode, roundDuration, rounds }` |
|
||||
| `player:left` / `player:rejoined` | S→C | `{ playerId }` |
|
||||
|
||||
### Soumission blindtest (phase pré-partie)
|
||||
|
||||
| Event | Sens | Payload |
|
||||
|---|---|---|
|
||||
| `blindtest:submitTrack` | C→S | `{ youtubeUrl }` — privé, jamais rebroadcast tel quel |
|
||||
| `blindtest:submitOk` | S→C | `{ accepted, reason? }` (vérifie embedding autorisé) |
|
||||
|
||||
### Déroulé d'une manche
|
||||
|
||||
| Event | Sens | Payload |
|
||||
|---|---|---|
|
||||
| `round:start` | S→C | `{ type, djId?, endsAt, payload }` (payload SANS la réponse) |
|
||||
| `media:control` | C→S | `{ action: 'play'|'pause'|'seek', positionSec }` — **DJ uniquement** |
|
||||
| `media:sync` | S→C | `{ action, positionSec, atServerTs }` → les clients se recalent |
|
||||
| `round:vote` | C→S | `{ answer }` (selon le mode, voir ci-dessous) |
|
||||
| `round:voteAck` | S→C | `{ count, total }` — progression anonyme |
|
||||
| `round:reveal` | S→C | `{ truth, perPlayerResult }` |
|
||||
| `score:update` | S→C | `{ scores }` |
|
||||
| `game:end` | S→C | `{ finalScores, spotifyExport? }` |
|
||||
|
||||
### Forme du vote selon `blindtestMode`
|
||||
|
||||
- `title_artist` → `{ title, artist }` (texte libre, matching tolérant).
|
||||
- `who_added` → `{ guessedPlayerId }`.
|
||||
- `mixed` → `{ title, artist, guessedPlayerId }` (points séparés).
|
||||
|
||||
---
|
||||
|
||||
## 6. Flow détaillé — Blindtest
|
||||
|
||||
```
|
||||
PHASE 1 — Soumission (lobby)
|
||||
Chaque joueur envoie N titres (youtubeUrl) en privé.
|
||||
Serveur vérifie l'embedding, stocke { trackId, submittedBy, url }.
|
||||
Les titres sont mélangés ; submittedBy reste secret.
|
||||
|
||||
PHASE 2 — Manche (répétée par titre)
|
||||
start():
|
||||
- tire un DJ NEUTRE = joueur != submittedBy de ce titre
|
||||
(si impossible, re-tire ; jamais le contributeur)
|
||||
- payload = { trackId, youtubeId } SANS submittedBy ni réponse
|
||||
- broadcast round:start (+ endsAt = now + roundDuration)
|
||||
pendant la manche :
|
||||
- DJ envoie media:control (play/pause/seek)
|
||||
- serveur rebroadcast media:sync à tous → recalage
|
||||
- chaque joueur (DJ INCLUS, à l'aveugle) envoie round:vote
|
||||
→ le DJ vote comme les autres pour ne pas perdre de points ;
|
||||
il n'a aucun avantage (mode who_added : il n'est pas le
|
||||
contributeur ; mode title_artist : il ne connaît pas plus
|
||||
la réponse).
|
||||
fin : timer écoulé OU tous votés
|
||||
reveal():
|
||||
- dévoile titre, artiste, ET submittedBy
|
||||
score():
|
||||
- title_artist : points si match titre/artiste (tolérance fuzzy)
|
||||
- who_added : points si guessedPlayerId == submittedBy
|
||||
- mixed : addition des deux barèmes
|
||||
|
||||
PHASE 3 — Fin de partie
|
||||
Génère l'export : liens de recherche Spotify (ou playlist via API)
|
||||
à partir des { title, artist } révélés.
|
||||
```
|
||||
|
||||
**Point d'attention — asymétrie d'attention du DJ :** il gère la lecture pendant que les autres se concentrent sur leur réponse. Sur 60 s c'est négligeable. Option *non prioritaire* : laisser le vote du DJ ouvert ~5 s de plus. À ne pas sur-ingénierer en v1.
|
||||
|
||||
---
|
||||
|
||||
## 7. Flow détaillé — Quiz culture
|
||||
|
||||
Le quiz n'a **pas de DJ**. Une question = une manche. Quatre formats partagent le même cycle `start → vote → reveal → score`, seul le payload et le barème changent.
|
||||
|
||||
### 7.1 Sources de contenu (V1)
|
||||
|
||||
| Source | Formats fournis | Rôle |
|
||||
|---|---|---|
|
||||
| **Open Trivia DB** | `mcq`, `truefalse` | Seed automatique de la banque. Gratuit, réponses déjà validées. Tokens de session pour éviter la répétition. (EN → traduisible en FR au seed.) |
|
||||
| **Manuel (back-office)** | `free`, `image_reveal` (+ mcq/truefalse maison) | Écrit par nous. Apporte le FR, l'actu, le ton perso/private jokes. Vérifié par construction. |
|
||||
|
||||
> **Pas d'IA en V1** → pas de problème de vérification. Open Trivia est pré-validé ; le manuel est écrit par nous. Génération IA = piste *optionnelle* plus tard si la saisie manuelle lasse.
|
||||
|
||||
**Anti-répétition :** à chaque question jouée, on insère dans `quiz_played`. La sélection exclut ce qui a déjà été vu par le groupe → la banque grossit, les répétitions disparaissent.
|
||||
|
||||
### 7.2 Les quatre formats
|
||||
|
||||
```
|
||||
COMMUN à tous :
|
||||
start(): pioche une question NON déjà jouée (quiz_played), payload SANS la réponse
|
||||
fin: timer écoulé OU tous votés
|
||||
reveal(): dévoile la bonne réponse + qui a eu juste
|
||||
score(): barème selon le format (+ bonus rapidité ∝ temps restant)
|
||||
|
||||
── mcq ──────────────────────────────────
|
||||
payload : { prompt, choices } (sans correct_index)
|
||||
vote : { choiceIndex }
|
||||
score : juste si choiceIndex == correct_index
|
||||
|
||||
── truefalse ────────────────────────────
|
||||
identique à mcq avec choices = ["Vrai","Faux"]
|
||||
|
||||
── free (réponse libre) ──────────────────
|
||||
payload : { prompt }
|
||||
vote : { text }
|
||||
score : matching TOLÉRANT contre accepted_answers
|
||||
(normalisation : minuscules, accents, espaces ;
|
||||
distance de Levenshtein pour les fautes de frappe)
|
||||
|
||||
── image_reveal (image qui se dévoile) ──
|
||||
payload : { imageUrl, revealSchedule }
|
||||
mécanisme : l'image se révèle progressivement (flou qui diminue
|
||||
ou masque qui se lève) par paliers pilotés par le
|
||||
timer SERVEUR. Le client n'affiche que l'état reçu.
|
||||
vote : { text } — on peut répondre à tout moment
|
||||
score : DÉGRESSIF — plus on trouve tôt (image encore floue),
|
||||
plus on marque. Le palier atteint au moment du vote
|
||||
détermine les points. Matching tolérant comme `free`.
|
||||
```
|
||||
|
||||
### 7.3 Image progressive — stockage & révélation
|
||||
|
||||
**Stockage (V1) :** **volume Docker monté + servi en statique par Fastify.** Upload via le back-office ; `image_url` en DB pointe vers le chemin servi. Simple, ~0 RAM, large pour quelques centaines d'images sur 100 Go de disque.
|
||||
> MinIO / stockage objet écarté : surdimensionné pour ce volume (RAM + service à maintenir pour un jeu entre potes). À reconsidérer seulement comme stockage mutualisé pour tout le homelab — décision infra transverse, pas de ce projet.
|
||||
|
||||
**Révélation :** mécanisme **front** (flou CSS décroissant, ou tuiles qui se découvrent) **cadencé par le serveur** : le serveur émet les paliers (`reveal:step`) pour que tous voient le même niveau de flou au même instant. Le score retenu = **palier actif côté serveur à la réception du vote** (jamais le client qui décide → non truquable sur le scoring).
|
||||
|
||||
**Anti-triche sur l'image — deux niveaux :**
|
||||
- **V1 (simple, suffisant entre potes) :** image complète envoyée, flou appliqué en CSS. Trichable via devtools par quelqu'un de motivé, mais l'auto-régulation sociale suffit. Le **scoring reste serveur-autoritaire** malgré tout.
|
||||
- **Plus tard (robuste) :** le serveur pré-génère les paliers avec `sharp` (versions floutées/pixelisées) et n'envoie que le palier courant — l'image nette n'atteint jamais le client avant le reveal. À coder seulement si la triche devient un vrai sujet.
|
||||
|
||||
### 7.4 Back-office de saisie (manuel)
|
||||
|
||||
Formulaire simple écrivant dans `quiz_question` :
|
||||
- choix du `format`, `prompt`, `category`, `difficulty`
|
||||
- `free` → liste de réponses acceptées
|
||||
- `image_reveal` → upload image + ordre de révélation
|
||||
- `mcq`/`truefalse` → choix + bonne réponse
|
||||
|
||||
Rien de lourd : une page protégée suffit pour la V1.
|
||||
|
||||
---
|
||||
|
||||
## 8. Extensibilité (post-V1)
|
||||
|
||||
Ajouter une épreuve = implémenter `GameRound` + définir son payload et son vote. Exemples :
|
||||
|
||||
- **GeoGuessr-like** : payload = coords / Street View embed ; vote = position devinée ; score = distance.
|
||||
- **Mini-épreuves rapides** type "réflexe culture" qui collent au tempo WarioWare.
|
||||
|
||||
Le reste (timer serveur, rooms, scoreboard, sync, lobby) est **mutualisé** et ne bouge pas.
|
||||
|
||||
---
|
||||
|
||||
## 9. Notes de déploiement (infra perso)
|
||||
|
||||
- Cible : VPS Hostinger **KVM 2 — 2 vCPU, 8 Go RAM, 100 Go**. Large pour ce jeu (Node + WebSocket + Postgres partagée restent légers).
|
||||
- Conteneurisable via Docker → déployable sur Dokploy.
|
||||
- WebSocket : s'assurer que le reverse proxy passe bien l'upgrade `Connection: upgrade`.
|
||||
- OAuth / clés API (YouTube Data, Spotify export) en variables d'environnement, jamais en dur.
|
||||
- HTTPS via le domaine existant → redirect URIs OAuth valides.
|
||||
- Le seed Open Trivia DB : script one-shot au déploiement (respecter la limite 1 req / 5 s).
|
||||
```
|
||||
85
CLAUDE.md
Normal file
85
CLAUDE.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
Référence d'architecture complète : voir `ARCHITECTURE.md` à la racine.
|
||||
|
||||
## What is NerdWare
|
||||
|
||||
Party game web multi-épreuves, ambiance WarioWare, centré culture geek (jeux vidéo, manga, pop culture). Chaque joueur joue sur son propre PC, rooms synchronisées en temps réel via Socket.IO. V1 = Blindtest (YouTube) + Quiz culture.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
bun install # Install all workspace dependencies (NEVER use npm/pnpm/yarn)
|
||||
bun run dev # Start all apps/packages in dev mode (Turborepo)
|
||||
bun run build # Build everything
|
||||
bun run lint # Lint all workspaces
|
||||
bun run typecheck # Type-check all workspaces
|
||||
bun run format # Format all workspaces with Prettier
|
||||
|
||||
# Per-workspace (from root)
|
||||
bun run --filter web dev # Dev server for client only
|
||||
bun run --filter @nerdware/server dev # Dev server for backend only (once created)
|
||||
|
||||
# Per-workspace (from workspace dir)
|
||||
cd apps/web && bun run dev
|
||||
```
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
Turborepo + Bun workspaces. Convention `apps/*` (runnable) + `packages/*` (shared libraries).
|
||||
|
||||
- `apps/web` — Vite + React client (`web` in package.json, imports `@workspace/ui`)
|
||||
- `packages/ui` — shadcn/ui components (`@workspace/ui`), exports via `./components/*`, `./lib/*`, `./hooks/*`, `./globals.css`
|
||||
- `apps/server` — **TO CREATE** — Fastify + Socket.IO backend (`@nerdware/server`)
|
||||
- `packages/shared` — **TO CREATE** — Shared WS event types + domain (`@nerdware/shared`), imported by both web and server
|
||||
|
||||
## Stack Constraints — DO NOT suggest alternatives
|
||||
|
||||
These choices are final. Never suggest Express, Prisma, Next.js, Spotify for audio, npm/pnpm/yarn, or any replacement.
|
||||
|
||||
| Layer | Choice |
|
||||
|---|---|
|
||||
| Language | TypeScript (strict, front + back) |
|
||||
| Package manager / runtime | **Bun** |
|
||||
| Monorepo | Turborepo |
|
||||
| Backend | Fastify + Socket.IO |
|
||||
| DB | PostgreSQL + Drizzle ORM (quiz content + history only; rooms live in-memory) |
|
||||
| Frontend | Vite + React |
|
||||
| Routing | wouter |
|
||||
| Client state | Zustand (mirrors state pushed by Socket.IO) |
|
||||
| HTTP data fetching | TanStack Query (back-office quiz + auth ONLY, NOT real-time game) |
|
||||
| UI | Tailwind v4 + shadcn/ui (Radix), Lucide icons |
|
||||
| Fonts | Outfit (body) + Oxanium (headings/arcade), self-hosted via Fontsource |
|
||||
| Animations | Framer Motion |
|
||||
| Audio | YouTube IFrame Player API (not Spotify) |
|
||||
| Images | Docker volume served statically by Fastify (no S3/MinIO in V1) |
|
||||
|
||||
## Architecture Principles (non-negotiable)
|
||||
|
||||
1. **Authoritative server.** Timer, scores, round order, DJ selection — all server-side. Client only displays received state. Never trust the client.
|
||||
2. **Shared WS types.** All Socket.IO events typed in `packages/shared`, imported by both sides. Malformed event = compile error.
|
||||
3. **Generic game engine.** Each game mode implements the same `GameRound` interface (`start / submitAnswer / reveal / score`). Adding a game mode = one module, no plumbing changes.
|
||||
4. **In-memory rooms.** Server-side `Map`. No Redis in V1.
|
||||
5. **Round ends** on first condition met: timer expired OR all eligible votes received.
|
||||
|
||||
## Code Conventions
|
||||
|
||||
- No semicolons, double quotes, trailing commas `es5`, 2-space indent (see `.prettierrc`)
|
||||
- Prettier with `prettier-plugin-tailwindcss` (functions: `cn`, `cva`)
|
||||
- ESLint flat config (`eslint.config.js` per workspace)
|
||||
- TS strict mode. No unjustified `any`.
|
||||
- Package names: `@nerdware/*` (note: `packages/ui` is currently `@workspace/ui` from shadcn preset)
|
||||
- Env vars for secrets (YouTube API keys, DB) — never hardcoded
|
||||
|
||||
## Implementation Order (V1 roadmap)
|
||||
|
||||
1. Create `packages/shared` (domain types + WS events) and `apps/server` (Fastify + Socket.IO startup)
|
||||
2. First real-time cycle: `room:create` / `room:join` + `room:state` broadcast + minimal lobby client
|
||||
3. Generic game engine (`GameRound` interface + orchestration loop + server timer)
|
||||
4. Quiz game mode end-to-end (simplest — no DJ, no audio)
|
||||
5. Drizzle setup + schema + Open Trivia DB seed
|
||||
6. Blindtest game mode (YouTube audio sync + DJ role) — highest technical risk
|
||||
7. Manual quiz back-office
|
||||
8. Polish: Framer Motion animations, transition screens, end-game + Spotify export
|
||||
Loading…
Add table
Reference in a new issue