Un jeu de devinettes quotidien basé sur Wikipédia. Chaque jour, une page Wikipédia est sélectionnée, son texte est découpé et ses mots sont masqués. Le joueur devine des mots pour révéler progressivement l'article, débloquer des indices et finir la partie.
- Aperçu
- Stack technique
- Prérequis
- Installation & configuration
- Commandes utiles
- Démarrage local (Bun)
- Développement avec Docker
- Architecture et organisation du repo
- Similarité des mots
- API & documentation (OpenAPI / Swagger)
- Base de données & Prisma
- Qualité, tests et CI
- Contribuer
- Ajouter un provider OAuth (BetterAuth)
- Licence
- Article quotidien tiré de Wikipédia
- Masquage de mots avec maintien de ponctuation
- Correspondance floue pour accepter les propositions proches
- Indices images progressifs
- Authentification (BetterAuth + Discord)
- Sauvegarde d'état pour utilisateurs authentifiés
- Mode coopératif en temps réel
- Framework: Next.js 16 (App Router)
- UI: React 19
- Langage: TypeScript 5 (strict)
- Styling: Tailwind CSS 4
- Client state: React Context + hooks
- HTTP / Fetching: TanStack Query / axios
- Base de données: PostgreSQL + Prisma 7
- Auth: BetterAuth (Prisma adapter) with Discord social provider
- Runtime & package manager: Bun
- Linter / Formatter: Biome
- Tests: Bun test
- Bun (v1+)
- Node / npm non requis (Bun utilisé)
- Docker & Docker Compose (optionnel, pour Postgres local)
- PostgreSQL (local ou distant)
Le projet est un monorepo Next.js avec code frontend et backend cohabitant dans src/. La séparation principale est :
src/app/: pages App Router, métadonnées, routes API et middleware de rendu côté serveursrc/components/: UI réutilisables et présentation des pagessrc/hooks/: hooks de logique clientsrc/lib/: logique serveur, services, repositories, validation, auth et utilitaires partagéssrc/context/etsrc/provider/: providers React et contextes applicatifssrc/types/: types partagéssrc/utils/: aides et helperssrc/test/: suites de tests Bunprisma/: schéma et migrations de base de donnéesgenerated/prisma/: client Prisma généré (ne pas modifier)public/: ressources statiques
src/app/layout.tsx: layout global, Navbar, Footer, providerssrc/app/page.tsx: page d'accueil principalesrc/app/error.tsx,loading.tsx,not-found.tsx: UI d'état globalsrc/app/robots.ts,src/app/sitemap.ts: génération de robots/sitemapsrc/app/api/: route handlers côté serveurauth/: BetterAuth callback et auth routegame/: endpoints de jeu (route.ts,guess,complete,state,reveal,yesterday,hint,hint/image)historic/: historique des articlesleaderboard/: classementsprofile/stats/: statistiques de profil auth
src/components/: composants de jeu, historique, leaderboard, profil, coop, UI partagéesrc/constants/: constantes de jeu, taux limites, thèmes et navigationsrc/context/: contextes globaux (CoopContext.ts,GameContext.ts,LoginContext.tsx)src/hooks/: hooks spécifiques pour article, auth, coop, jeu, db, état de partie, etc.src/provider/: providers React (CoopProvider.tsx,GameProvider.tsx,LoginProvider.tsx,QueryProvider.tsx)
src/env.ts: validation des variables d'environnement avec Zodsrc/instrumentation.ts: démarrage, vérification DB et bootstrap de l'article quotidiensrc/proxy.ts: injection d'en-têtes de requête côté serveursrc/lib/prisma.ts: singleton Prismasrc/lib/db-check.ts: vérification de la connexion DBsrc/lib/auth/: configuration BetterAuth et clients authsrc/lib/services/: cas d'utilisation applicatifs (jeu, profil, historique, leaderboard)src/lib/repositories/: accès DB Prisma et abstractions de donnéessrc/lib/controllers/: validation des requêtes et réponse HTTPsrc/lib/game/: logique de jeu pure, normalisation, rotation quotidienne, fetch wikisrc/lib/errors/: erreurs partagées et formatage d'erreursrc/lib/query/: helpers de requêtes communessrc/lib/supabase/: integration Supabase Realtime pour le mode coopératifsrc/lib/batches/: tâches batch et gestion de données
src/test/: tests unitaires et d'intégration Bungame.test.ts,normalize.test.ts,hintImage.test.ts,rate-limit.test.ts,historic.test.ts, etc.
src/test/mocks/: données et utilitaires de test
package.json: scripts Bun et dépendancesbunfig.toml: configuration Bun pour les testsbiome.json: configuration Biometsconfig.json: configuration TypeScriptnext.config.ts: configuration Next.jsprisma.config.ts: configuration Prismadocker-compose.yml: environnement local DockerDockerfile: image de productiondocker-entrypoint.sh: entrée du conteneurscripts/: scripts de migration et migration de l'ancien auth
prisma/schema.prisma: modèle de donnéesprisma/migrations/: historique des migrationsgenerated/prisma/: client Prisma généré
Cette section explique la logique de similarité des mots utilisée par WikiGuessr et pourquoi nous préférons l'algorithme de Levenshtein à Jaro–Winkler pour la détection de propositions proches.
Levenshtein calcule le nombre minimum d'opérations élémentaires nécessaires pour transformer une chaîne en une autre : insertion, suppression ou substitution d'un caractère.
Exemple simple :
- mot A :
chat - mot B :
chats
Il suffit d'insérer le caractère s pour transformer chat en chats → distance = 1.
Nous normalisons la distance pour obtenir un score de similarité entre 0 et 1 :
Dans l'exemple ci‑dessus :
Un score proche de 1 signifie que les mots sont très proches ; un score proche de 0 signifie qu'ils sont très différents.
Nous avons testé Jaro–Winkler mais constaté qu'il ramenait trop de bruit pour notre cas d'usage. Concrètement, Jaro–Winkler attribuait souvent des scores élevés à des paires de mots qui restaient significativement différentes dans le contexte du jeu, ce qui augmentait les faux positifs et pouvait induire en erreur les joueurs (propositions acceptées ou considérées comme « proches » alors qu'elles n'étaient pas suffisamment pertinentes).
Pour ces raisons nous privilégions Levenshtein (avec normalisation) : il est simple, interprétable et nous permet d'ajuster précisément le seuil de similarité pour l'expérience de jeu.
Un seuil raisonnable pour accepter une proposition comme "similaire" peut se situer autour de 0.75–0.85 selon le niveau de tolérance souhaité ; ajuster ce seuil en fonction des retours joueurs et des tests opératifs est recommandé.
- Le backend utilise BetterAuth pour l'authentification.
- Les routes API se trouvent dans
src/app/api/et restent légères : elles orchestrent les contrôleurs, l'auth et les services. - Ne pas modifier
generated/prisma/manuellement
Ci-dessous un résumé des principales routes API exposées par l'application, le verbe HTTP, la nécessité d'authentification et le format du body attendu (JSON). Les routes renvoient des réponses JSON sauf indication contraire.
-
GET /api/game
- Auth: Non
- Body: Aucun
- Description: Récupère l'article masqué du jour (MaskedArticle).
-
POST /api/game/guess
- Auth: Non
- Body: { "word": "string" (required), "revealedWords": ["string", ...] (optional) }
- Description: Soumet une proposition de mot et reçoit le résultat de la recherche.
-
POST /api/game/complete
- Auth: Oui
- Body: { "guessCount": number (required), "guessedWords": ["string", ...] (required), "hintsUsed": number (optional) }
- Description: Persiste une partie vérifiée pour un utilisateur authentifié.
-
GET /api/game/state
- Auth: Oui
- Body: Aucun
- Description: Récupère l'état sauvegardé (
GameCache) pour l'utilisateur.
-
PUT /api/game/state
- Auth: Oui
- Body: GameCache { "guesses": [{ "word": string, "found": boolean, ... }, ...], "revealed": { "": "displayText", ... }, "saved": boolean (optional), "revealedImages": ["string", ...] (optional) }
- Description: Enregistre l'état de la partie pour l'utilisateur connecté.
-
POST /api/game/reveal
- Auth: Non
- Body: { "words": ["string", ...] }
- Description: Demande de révéler une liste de mots (utilisé pour vérification côté client/outil).
-
GET /api/game/yesterday
- Auth: Non
- Body: Aucun
- Description: Récupère le titre de l'article d'hier. Réponse: { "title": string }
-
POST /api/game/hint
- Auth: Optionnel (auth disponible mais non obligatoire)
- Body: { "hintIndex": number (required), "guesses": ["string", ...] (optional), "won": boolean (optional) }
- Description: Demande un indice (image) ; certains indices peuvent nécessiter un utilisateur authentifié ou conditions métier.
-
GET /api/game/hint/image?index=
- Auth: Non
- Query:
index(required, integer) - Body: Aucun
- Description: Retourne l'image d'indice obfusquée en
image/webppour l'index donné.
-
GET /api/historic
- Auth: Non
- Body: Aucun
- Description: Liste les articles historiques disponibles.
-
GET /api/leaderboard
- Auth: Non
- Body: Aucun
- Description: Récupère les classements publics.
-
GET /api/profile/stats
- Auth: Oui
- Body: Aucun
- Description: Statistiques de profil pour l'utilisateur connecté.
-
Routes d'authentification (BetterAuth)
- Base:
/api/auth/[...betterauth] - Description: Toutes les routes d'auth sont gérées par BetterAuth (échange OAuth, session, callbacks). Voir la configuration dans
src/lib/auth/.
- Base:
-
Coop (temps réel)
- Base:
/api/coop/* - Exemples (JSON bodies):
POST /api/coop(create lobby): { "displayName": "string", "userId": "string" (optional) }POST /api/coop/join: { "code": "STRING", "displayName": "string", "userId": "string" (optional) }GET /api/coop/{code}: état du lobbyPOST /api/coop/{code}/start: { "playerToken": "string" } (leader uniquement)POST /api/coop/{code}/guess: { "playerToken": "string", "word": "string" }POST /api/coop/{code}/leave: { "playerToken": "string" }POST /api/coop/{code}/restart: { "playerToken": "string" } (leader uniquement)POST /api/coop/{code}/abandon: { "playerToken": "string" } (leader uniquement)
- Description: Endpoints pour la création/jointure de lobby et le jeu coopératif (voir
src/app/api/coop/).
- Base:
Chaque route est décrite avec une courte présentation, un exemple d'appel et un exemple de réponse (HTTP 200 ou type attendu). Les exemples utilisent http://localhost:3000 comme base locale.
Retourne l'article masqué du jour (structure MaskedArticle).
Exemple :
GET http://localhost:3000/api/game
Réponse 200 :
{
"sections": [ /* MaskedSection[] */ ],
"totalWords": 123,
"date": "2026-04-28",
"imageCount": 2
}Soumet un mot proposé par le joueur et renvoie le résultat de la recherche (positions, similarité, occurrences...).
Exemple :
POST http://localhost:3000/api/game/guess
Content-Type: application/json
{
"word": "Napoléon",
"revealedWords": ["empereur"]
}
Réponse 200 :
{
"found": true,
"word": "Napoléon",
"positions": [ /* WordPosition[] */ ],
"occurrences": 2,
"similarity": 1.0,
"serverDate": "2026-04-28T12:34:56.000Z"
}Persiste une partie validée pour l'utilisateur authentifié.
Exemple :
POST http://localhost:3000/api/game/complete
Authorization: Bearer <token>
Content-Type: application/json
{
"guessCount": 12,
"guessedWords": ["mot1","mot2","mot3"],
"hintsUsed": 1
}
Réponse 200 :
{
"success": true,
"resultId": "abc123"
}Récupère l'état sauvegardé (GameCache) pour l'utilisateur connecté.
Exemple :
GET http://localhost:3000/api/game/state
Authorization: Bearer <token>
Réponse 200 :
{
"state": {
"guesses": [ /* StoredGuess[] */ ],
"revealed": { "tokenId": "texte affiché" },
"saved": true,
"revealedImages": ["/images/hint1.webp"]
}
}Enregistre l'état de la partie pour l'utilisateur connecté (forme GameCache).
Exemple :
PUT http://localhost:3000/api/game/state
Authorization: Bearer <token>
Content-Type: application/json
{
"guesses": [{ "word":"Paris","found":true,"occurrences":1 }],
"revealed": { "t_1": "Paris" },
"saved": true
}
Réponse 200 :
{ "success": true }Demande de révéler une liste de mots (utilisé pour vérification ou outils).
Exemple :
POST http://localhost:3000/api/game/reveal
Content-Type: application/json
{ "words": ["mot1","mot2"] }
Réponse 200 :
{
"positions": [ /* WordPosition[] */ ]
}Renvoie le titre de l'article d'hier.
Exemple :
GET http://localhost:3000/api/game/yesterday
Réponse 200 :
{ "title": "Article d'hier" }Demande un indice (image). Authentication optionnelle — si l'utilisateur est connecté, la logique métier peut débloquer plus d'options.
Exemple :
POST http://localhost:3000/api/game/hint
Content-Type: application/json
{
"hintIndex": 0,
"guesses": ["mot1"],
"won": false
}
Réponse 200 :
{
"imageUrl": "https://.../hint0.webp",
"hintIndex": 0,
"totalImages": 3
}Récupère l'image d'indice obfusquée (réponse binaire image/webp).
Exemple :
GET http://localhost:3000/api/game/hint/image?index=0
Réponse 200 :
- Content-Type: image/webp (corps binaire)
- Headers:
X-WikiGuessr-Obfuscation,Cache-Control
Liste les articles historiques disponibles.
Exemple :
GET http://localhost:3000/api/historic
Réponse 200 :
[
{ "id": 1, "title":"...", "date":"2026-04-01", "url":"...", "resolvedCount": 12 }
]Récupère les classements publics.
Exemple :
GET http://localhost:3000/api/leaderboard
Réponse 200 :
{
"categories": [ /* LeaderboardCategoryData[] */ ]
}Statistiques du profil pour l'utilisateur connecté.
Exemple :
GET http://localhost:3000/api/profile/stats
Authorization: Bearer <token>
Réponse 200 :
{ /* statistiques utilisateur */ }Crée un lobby coopératif et retourne le code + token joueur.
Exemple :
POST http://localhost:3000/api/coop
Content-Type: application/json
{ "displayName": "Alice", "userId": "optional-user-id" }
Réponse 200 :
{ "code": "ABC123", "playerId": 1, "playerToken": "tok..", "isLeader": true }Rejoint un lobby existant.
Exemple :
POST http://localhost:3000/api/coop/join
Content-Type: application/json
{ "code": "ABC123", "displayName": "Bob" }
Réponse 200 :
{ "code": "ABC123", "playerId": 2, "playerToken": "tok..", "isLeader": false }Récupère l'état du lobby (remplacer {code} par le code du lobby).
Exemple :
GET http://localhost:3000/api/coop/ABC123
Réponse 200 :
{ /* état du lobby : joueurs, statut, leader, etc. */ }Démarre la partie coop (le body doit contenir playerToken ; la route vérifie que le joueur est leader).
Exemple :
POST http://localhost:3000/api/coop/ABC123/start
Content-Type: application/json
{ "playerToken": "tok.." }
Réponse 200 :
{ "article": { /* MaskedArticle */ } }Soumet une proposition en mode coop (rate-limited).
Exemple :
POST http://localhost:3000/api/coop/ABC123/guess
Content-Type: application/json
{ "playerToken": "tok..", "word": "Paris" }
Réponse 200 :
{ /* GuessResult + "won": boolean */ }Permet à un joueur de quitter le lobby.
Exemple :
POST http://localhost:3000/api/coop/ABC123/leave
Content-Type: application/json
{ "playerToken": "tok.." }
Réponse 200 :
{ "success": true }Redemarre la partie coop (le body doit contenir playerToken ; la route vérifie que le joueur est leader).
Exemple :
POST http://localhost:3000/api/coop/ABC123/restart
Content-Type: application/json
{ "playerToken": "tok.." }
Réponse 200 :
{ "article": { /* MaskedArticle */ } }Abandonne et ferme définitivement le lobby (leader uniquement).
Exemple :
POST http://localhost:3000/api/coop/ABC123/abandon
Content-Type: application/json
{ "playerToken": "tok.." }
Réponse 200 :
{ "success": true }Routes d'authentification gérées par BetterAuth (OAuth, sessions, callbacks). Le détail dépend de l'opération BetterAuth.
Exemples :
GET /api/auth/session
POST /api/auth/callback/discord
Réponse : variable selon l'opération BetterAuth (JSON ou redirection OAuth).
- Cloner le dépôt
git clone https://github.com/Wiibleyde/WikiGuessr.git
cd WikiGuessr
bun install- Copier les variables d'environnement
cp .env.example .env- Variables d'environnement importantes (extrait)
DATABASE_URL(Postgres)BETTER_AUTH_SECRET(min 32 chars)BETTER_AUTH_URL(BetterAuth base URL)DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET(Discord OAuth)NEXT_PUBLIC_SUPABASE_URL,NEXT_PUBLIC_SUPABASE_ANON_KEY(optionnel pour Realtime)GAME_TIMEZONE(optionnel, défaut Europe/Paris)
Compléter le .env à partir du .env.example fourni.
bun run dev # Serveur de développement (Next.js)
bun run build # Build de production
bun run start # Lancer la build en production
bun run lint # Biome lint
bun run format # Biome format
bun run test # Tests Bun
bun run test:watch # Tests en watch
bun run db:generate # Générer Prisma client
bun run db:migrate # Appliquer les migrations- Démarrer les services Supabase (PostgreSQL + Realtime)
docker compose up -d- Appliquer les migrations et générer le client
bun run db:migrate
bun run db:generate- Lancer l'app
bun run devL'application sera disponible sur http://localhost:3000.
Le projet contient un docker-compose.yml permettant de lancer PostgreSQL et, si besoin, de builder l'application (partie commentée du fichier).
docker compose up --buildLe Dockerfile et docker-entrypoint.sh gèrent la génération du client Prisma et le démarrage.
Structure principale (résumé)
src/
├── app/ # App Router pages, metadata et routes API
├── components/ # Composants UI (game, navbar, profile...)
├── constants/ # Constantes métier
├── context/ # Context providers (Coop, Game, Login)
├── hooks/ # Hooks client (useArticle, useGame, useGuess...)
├── lib/ # Backend logic: controllers, services, repos, game
├── provider/ # Providers React (CoopProvider, GameProvider)
└── test/ # Tests Bun
prisma/ # schema.prisma + migrations
generated/prisma/ # Client Prisma généré (ne pas modifier)
Règles d'organisation
app/*: pages + routes API (Next.js App Router)lib/controllers/*: validation HTTP et shaping des réponseslib/services/*: cas d'utilisation métierlib/repositories/*: accès à la base via Prismalib/game/*: logique purement liée au jeu (normalisation, tokenisation)
- Fichiers principaux:
prisma/schema.prismaet le dossierprisma/migrations/. - Le client Prisma généré se trouve dans
generated/prisma/.
Migrations courantes
bun run db:migrateGénération du client (si nécessaire)
bun run db:generateImportant: ne pas modifier manuellement generated/prisma/.
- Linter/formatter: Biome (
bun run lint,bun run format) - Tests: Bun test (
bun run test) — la suite de tests actuelle passe localement. - CI: workflows GitHub Actions présents pour lint, tests et build Docker.
Conseils avant PR
bun run lint
bun run test
bun run build- Si Prisma client est manquant:
bun run db:generate - Si migration bloquée: vérifier
DATABASE_URLet relancerbun run db:migrate - Si tests échouent localement: lancer
bun run test:watchpour debug
- Créer une branche
feature/xxxdepuisdevelop. - Respecter le style (TypeScript strict, Biome).
- Ajouter des tests pour toute nouvelle logique métier.
- Ouvrir une Pull Request vers
develop.
Exemple : ajouter Google en plus de Discord.
- Ajouter les variables dans
.env(et dans.env.examplepour documenter le setup) :
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret- Étendre la validation Zod dans
src/env.ts:
const envSchema = z.object({
// ...
GOOGLE_CLIENT_ID: z.string(),
GOOGLE_CLIENT_SECRET: z.string(),
});
const env = envSchema.parse({
// ...
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
});- Déclarer le provider dans BetterAuth (
src/lib/auth/auth.ts) :
socialProviders: {
discord: {
clientId: env.DISCORD_CLIENT_ID,
clientSecret: env.DISCORD_CLIENT_SECRET,
},
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
},
},-
Si vous voulez l'afficher dans l'UI de login, ajouter une entrée dans
src/constants/navbar.tsx(providers). -
Configurer l'URL de redirection dans le dashboard OAuth du provider (obligatoire) :
- Base app :
BETTER_AUTH_URL(ex:http://localhost:3000) - Route BetterAuth :
src/app/api/auth/[...betterauth]/route.ts - Callback OAuth attendue par BetterAuth :
${BETTER_AUTH_URL}/api/auth/callback/<provider>(ex:/api/auth/callback/google) - Exemple Discord local :
http://localhost:3000/api/auth/callback/discord
- Base app :
-
Redémarrer le serveur après changement de variables d'environnement.
Ce projet est distribué sous licence GPL-3.0.