01 — Architecture technique
TL;DR des décisions
| Question | Décision | Pourquoi en une ligne |
|---|---|---|
| Stack de départ | template vite-react-pocketbase | déjà React 19 + TS + Tailwind 4 + @gabvdl/ui + RxDB + scripts de déploiement |
| « Plutôt du NoSQL ? » | PocketBase, en l'utilisant comme un document store (colonnes json + schémas Zod) | on veut du document, pas un serveur de plus |
| Source de vérité du contenu | des fichiers JSON versionnés en git, PB est la surface d'édition | un graphe narratif se relit, se diffe, se git revert |
| État de partie | journal d'événements append-only + snapshots | cf. 02-event-sourcing.md — oui, l'event sourcing est le bon modèle |
| Moteur de jeu | package TypeScript pur, sans React, sans réseau, déterministe | permet de simuler 10 000 parties en CI pour équilibrer |
| Jeu et éditeur | une seule application, deux routes, un seul modèle de données | l'éditeur doit pouvoir lancer une partie de test en un clic |
| Local-first | oui, RxDB en local, PB en synchro | on joue dans le train, et une partie est un objet personnel |
1. Les trois domaines de données
C'est la décomposition qui commande tout le reste. Ils n'ont ni le même rythme d'écriture, ni le même cycle de vie, ni le même propriétaire, donc ils ne doivent pas être stockés de la même façon.
┌─────────────────────────────────────────────────────────────────┐
│ A. CATALOGUE DE CONTENU écrit par l'auteur, lu par tous │
│ scénarios, personnes, éléments, scènes, incidents, graphe │
│ ~ quelques milliers de documents, lecture massive, │
│ écriture rare, versionné, doit être diffable │
│ → JSON en git (vérité) ⇄ PocketBase (édition) → bundle │
├─────────────────────────────────────────────────────────────────┤
│ B. ÉTAT DE PARTIE écrit par le moteur, lu par 1 │
│ journal d'événements + snapshots │
│ ~ quelques milliers d'événements par partie, append-only │
│ → RxDB local (autorité) → PocketBase (sauvegarde/synchro) │
├─────────────────────────────────────────────────────────────────┤
│ C. COMPTES & MÉTA PocketBase natif │
│ users, liste des sauvegardes, télémétrie d'équilibrage │
└─────────────────────────────────────────────────────────────────┘
Les erreurs classiques que cette séparation évite : mettre l'état de partie dans des tables relationnelles (ingérable), et mettre le contenu uniquement en base (ingérable à relire et à versionner).
2. La question NoSQL, tranchée
« intuitivement je pense qu'une base NoSQL serait mieux pour stocker des éléments de gameplay arbitraires »
L'intuition sur la forme est juste, la conclusion sur l'outil ne l'est pas.
Ce qu'il faut vraiment, c'est un modèle documentaire : des objets hétérogènes, imbriqués, polymorphes, dont le schéma bouge à chaque semaine de design. Pas une base NoSQL en tant que serveur.
Ce que PocketBase sait déjà faire
- Des colonnes de type
json, requêtables via SQLite JSON1 (payload.stats.metier > 60fonctionne dans un filtre). - L'auth, les règles d'accès par collection, le stockage de fichiers (photos de tournage, PDF de scénario, audio), le temps réel par abonnement, les migrations, et une interface admin gratuite.
- Un seul binaire, un seul volume, déjà déployé dix fois dans le lab.
Le coût d'un vrai NoSQL (Mongo, Couch, Surreal)
- Un service de plus à héberger, sauvegarder, mettre à jour.
- Plus d'auth, plus de règles, plus de stockage de fichiers, plus d'admin — donc il faudrait quand même PocketBase à côté, ou tout réécrire.
- Le volume ne le justifie pas : on parle de milliers de documents, pas de millions. SQLite est très largement au-dessus du besoin.
La forme retenue
// collection PocketBase `content`
{
id: string // ULID
pack: string // relation -> packs (un scénario = un pack)
kind: EntityKind // 'person' | 'scene' | 'incident' | ...
slug: string // unique par (pack, kind)
payload: object // ← le document, validé par un schéma Zod par `kind`
schema_version: number
status: 'draft' | 'review' | 'ready'
updated: string
}
Une seule collection polymorphe, indexée sur (pack, kind, slug). Le
payload est un document libre côté base et strictement typé côté code par
un schéma Zod par kind. On obtient la souplesse du document et la rigueur du
typage, sans serveur supplémentaire.
Le vrai garde-fou n'est pas la base, c'est le schéma applicatif. Un schéma Zod par
kind, partagé par le moteur, l'éditeur et uncontent:lintexécuté en CI. C'est ça qui empêche le contenu de pourrir — pas le choix Mongo/SQLite.
Le contenu vit en git, pas en base
Point important et peu intuitif : la base n'est pas la source de vérité du contenu.
content/<pack>/persons/*.json ← la vérité, versionnée, relue en PR
content/<pack>/scenes/*.json
content/<pack>/incidents/*.json
content/<pack>/graph.json
│
│ npm run content:import (git → PocketBase, pour éditer)
▼
PocketBase ← l'éditeur écrit ici, confortablement
│
│ npm run content:export (PocketBase → git, un fichier par entité,
▼ clés triées, diff lisible)
commit
│
│ npm run content:build (validation Zod + résolution des refs
▼ + bundle figé + hash)
dist/content-<hash>.json ← ce que le jeu charge
Pourquoi c'est le bon choix ici :
- Un fil narratif est du code. On veut le relire, le comparer, le revenir en
arrière, le brancher. Un
git diffsurincidents/chef-op-au-rabais.jsonest infiniment plus utile qu'un historique de table. - Le contenu est immuable au runtime : le bundle est figé et hashé, donc une partie sauvegardée sait exactement contre quelle version de contenu elle a été jouée — indispensable quand l'état est dérivé d'un journal (cf. §5).
- Ça permet de jouer hors ligne : le contenu est un asset statique.
- Le jour où quelqu'un d'autre écrit du contenu, une PR est déjà le bon processus.
Le prix à payer : un aller-retour import/export à discipliner, et un conflit
possible si on édite des deux côtés. Mitigation : l'export est déterministe
(clés triées, formatage stable) et content:export refuse de tourner sur un
working tree sale.
3. Découpage du code
Le point non négociable : le moteur ne connaît ni React, ni PocketBase, ni le réseau, ni l'horloge, ni le hasard.
lost-in-production/
├─ packages/
│ ├─ schema/ Zod : entités de contenu, événements, effets, conditions
│ │ → la seule source de vérité des types, importée partout
│ ├─ engine/ TypeScript pur, zéro dépendance runtime
│ │ ├─ reduce.ts (State, GameEvent) => State ← pur
│ │ ├─ resolve/ résolution d'un plan, d'une journée
│ │ ├─ director/ le directeur d'aléas
│ │ ├─ rules/ évaluateur de conditions, applicateur d'effets
│ │ ├─ rng.ts PRNG seedé, déterministe
│ │ └─ sim/ harnais de simulation headless
│ └─ ui/ composants partagés jeu ↔ éditeur (le stabilo, le calendrier,
│ la fiche personne) — au-dessus de @gabvdl/ui
├─ apps/
│ ├─ game/ l'écran de bureau, les fenêtres, la partie
│ └─ editor/ le backoffice (peut n'être qu'une route de `game` au début)
├─ content/ les packs JSON versionnés
├─ pocketbase/ migrations + hooks
└─ docs/ ← vous êtes ici
Ce que la pureté du moteur achète, concrètement :
- La simulation headless.
npm run sim -- --seeds 10000 --pack demofait tourner dix mille parties avec une IA basique et sort les distributions : combien finissent le film, à quel dépassement médian, quels incidents ne se déclenchent jamais, lesquels sont fatals dans 90 % des cas. C'est le seul moyen d'équilibrer un jeu de gestion — sans ça, on équilibre à l'oreille et on se trompe. - Les tests. Un test = un état de départ + une liste d'événements + une assertion sur l'état final. Rapide, sans DOM, sans base.
- Le rejeu. Même journal + même contenu + même seed = même partie, bit pour bit. C'est la condition de l'event sourcing et de la fonction « et si j'avais choisi l'autre option ? ».
- La validation de contenu. Le
content:lintréutilise l'évaluateur de conditions du moteur pour détecter les nœuds injouables.
Corollaires à faire respecter par lint (no-restricted-imports sur
packages/engine) :
- pas de
Math.random()— le hasard passe parrng(seed, …); - pas de
Date.now()— le temps est un champ de l'état ; - pas de
fetch, pas d'accès PocketBase, pas d'import React.
4. Le local-first
Une partie appartient au joueur et doit tourner sans réseau.
- RxDB (déjà dans le template) tient le journal d'événements et le snapshot courant en IndexedDB. C'est l'autorité pendant qu'on joue.
- PocketBase reçoit les événements par lots, pour la sauvegarde et le
multi-appareil. La synchro est triviale parce que le journal est
append-only : pas de fusion, juste une concaténation ordonnée par
(sessionId, seq). - Un conflit n'est possible que si la même partie avance sur deux appareils en même temps. Règle simple et suffisante : une session est verrouillée par appareil (bail renouvelé) ; sinon on fourche la partie et on le dit.
⚠️ Piège connu du lab : les écritures local-first ne se testent pas via browserless sur
host.docker.internal(pas decrypto.subtlehors contexte sécurisé). Tester en Playwright surlocalhost.
5. Versionnement du contenu et des sauvegardes
C'est la dette technique dont on ne se remet pas si on l'ignore.
Une sauvegarde event-sourcée n'est qu'une recette : elle ne vaut que si on a encore les ingrédients. Donc chaque session stocke :
type SessionHeader = {
sessionId: string
packId: string
contentHash: string // hash du bundle de contenu utilisé
engineVersion: string // semver du package engine
seed: string // graine maîtresse de la partie
startedAt: string
}
Politique retenue :
- Le rejeu à l'identique n'est garanti que pour
(contentHash, engineVersion)identiques. - Sinon on ne rejoue pas le journal : on repart du dernier snapshot, et on affiche « cette partie a été commencée avec une version antérieure du jeu ».
- Le snapshot est donc obligatoire, pas une optimisation. Un snapshot tous les N événements et à chaque fin de journée.
- Les migrations de snapshot sont numérotées et testées sur des fixtures réelles, comme les migrations PocketBase.
6. IA générative — où elle a sa place, et où elle n'en a pas
Le lab a un LM local (service brain / LM Studio sur EVOX2) et des générateurs
d'images. Utilisation raisonnable :
| Usage | Verdict |
|---|---|
| Proposer un dépouillement à partir du texte d'une scène, dans l'éditeur | ✅ excellent — l'auteur corrige, gain de temps énorme |
| Générer les portraits des personnes, les photos de plateau, les repérages | ✅ oui, en asset pipeline, généré une fois et commité |
| Reformuler un SMS de PNJ en trois variantes, à l'écriture | ✅ oui, en aide à l'auteur |
| Générer des aléas au runtime | ❌ non — non déterministe, donc incompatible avec le rejeu, invérifiable, hors ligne impossible, et la qualité d'écriture est le produit |
| Jouer le réalisateur en dialogue libre | ⚠️ tentant, très cher en cohérence ; à garder pour une v2, et hors du chemin critique de l'état |
Règle : l'IA est un outil d'atelier, pas un composant du moteur. Tout ce qu'elle produit est figé dans le contenu versionné avant d'atteindre une partie.
⚠️ Ne pas confondre procédural et IA. Le cadrage demande de la génération procédurale et de l'écriture à la main et des packs générés — les trois sont compatibles, à condition de séparer :
- procédural seedé (composer un vivier, tirer la météo, faire varier les tarifs, choisir quelles dettes sont armées) → déterministe, donc à sa place dans le moteur, au runtime ;
- génération LLM (écrire un texte, décliner un incident, proposer un dépouillement, produire un pack entier) → non déterministe, donc hors du moteur : à l'écriture ou en étape de « cuisson » d'un pack, figée et hashée avant d'être jouable.
Développé dans 02-event-sourcing.md §5 et 05-backoffice-editor.md §5.
7. Déploiement
Cohérent avec le reste du lab (cf. note-vite, insta-pics) :
- Front statique déployé par zipgo sur raspy2 — un nom en
*.game.gabvdl.xyz(le jeu) semble le bon rayon. - PocketBase en conteneur, exposé sur un sous-domaine
admin.<projet>avec le passage/api— c'est le motif déjà utilisé et il marche. - Le bundle de contenu est un asset statique du build : aucun appel réseau n'est nécessaire pour jouer, PocketBase ne sert qu'aux comptes, aux sauvegardes distantes et à l'éditeur.
- L'éditeur, lui, exige PocketBase et reste derrière l'auth.
8. Ce qui reste à trancher
Repris dans 08-open-questions.md :
- Monorepo npm workspaces + turbo dès le début, ou un seul paquet avec des dossiers et une règle de lint ? (le second est plus rapide à démarrer, le premier empêche vraiment les imports interdits)
- L'éditeur : route de l'app de jeu, ou application séparée ? (partagé au début, séparé quand il grossit)
- Le contenu est-il téléchargeable à chaud (nouveaux packs sans redéploiement) ou figé dans le build ?
- Les sauvegardes distantes dès la v1, ou local-only pour le prototype ?