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 → SERVERetSERVER → 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éesimage_reveal→ upload image + ordre de révélationmcq/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).