# 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 settings RoomSettings rounds Round[] -- planifiés currentRound int scores Map 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 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). ```