nerdware/ARCHITECTURE.md

15 KiB

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.

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'
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).