Le coin du techosThe techies’ corner
Sous le capot : de quoi est fait le jeu, comment ses morceaux se parlent, et pourquoi on a choisi de faire (presque) tout à la main. Pour les curieux, les confrères et les futurs nous-mêmes.Under the hood: what the game is made of, how its parts talk to each other, and why we chose to do (almost) everything by hand. For the curious, for fellow developers, and for our future selves.
TypeScriptthree.js · WebGPURust → WebAssemblyFirebaseCapacitor · ElectronGemini
En une phraseIn one sentence
Un seul jeu web, écrit en TypeScript et dessiné avec three.js (WebGPU, avec repli WebGL), qui tourne tel quel dans un navigateur, dans les apps iOS et Android (le même code dans une coque Capacitor) et dans l’application de bureau Mac / Windows (Electron). Les rues viennent de vraies données cartographiques françaises ; les murs des bâtiments sont fabriqués par un petit module Rust compilé en WebAssembly ; la coopération passe par Firebase (en ligne) ou par un relais Wi-Fi (en local). Il n’y a aucun serveur de jeu : l’hôte d’une partie, c’est l’un des joueurs.A single web game, written in TypeScript and drawn with three.js (WebGPU, with a WebGL fallback), that runs unchanged in a browser, in the iOS and Android apps (the same code in a Capacitor shell) and in the Mac / Windows desktop app (Electron). The streets come from real French map data; building walls are produced by a small Rust module compiled to WebAssembly; co-op goes through Firebase (online) or a Wi-Fi relay (local). There is no game server: the host of a match is one of the players.
L’architecture applicativeThe application architecture
De haut en bas : où le jeu s’exécute, ce qu’il est, de quoi il se sert, et où vont ses données.Top to bottom: where the game runs, what it is, what it relies on, and where its data goes.
Deux versions, un moteurTwo versions, one engine
- La v1 est une promenade dans une vraie ville ; la v2 (Dead Streets) a été bâtie par-dessus. Les deux partagent le moteur de ville de
src/et ne diffèrent que par leur point d’entrée (index.htmletv2.html) : Vite construit les deux, et un script range la v2 à la racine du site et la v1 sous/v1/.v1 is a stroll through a real city; v2 (Dead Streets) was built on top of it. Both share the city engine insrc/and only differ by their entry point (index.htmlandv2.html): Vite builds both, and a script puts v2 at the site root and v1 under/v1/. - L’application embarque la v2 seule, avec quelques villes ; les autres se téléchargent à la demande (
cityFetch), sans jamais bloquer le jeu.The app bundles v2 only, with a few cities; the others are downloaded on demand, never blocking the game.
Une ville à la demandeA city on demand
- Le sol est découpé en tuiles de 100 m, créées à la volée à partir de la photo aérienne, du réseau routier et du relief (RGE ALTI). Rien n’est chargé hors du champ du joueur.The ground is cut into 100 m tiles, created on the fly from the aerial photo, the road network and the relief (RGE ALTI). Nothing outside the player’s surroundings is loaded.
- Pour une ville du menu, un paquet précalculé ; pour une adresse libre, on interroge l’IGN en direct, par une file unique qui plafonne le débit (jamais plus de 20 appels par seconde glissante).For a menu city, a precomputed bundle; for a free address, we query the IGN live through a single queue that caps the rate (never more than 20 calls per sliding second).
L’architecture logicielleThe software architecture
Ici, pas de framework de jeu : un moteur de rendu (three.js), du TypeScript strict, et des modules qui s’assemblent comme des briques. Voici les principes qui tiennent l’ensemble.No game framework here: a rendering engine (three.js), strict TypeScript, and modules that snap together like bricks. These are the principles holding it all up.
Des modules-fabriquesFactory modules
- Presque chaque fichier de
src/v2exporte une fabriquecreateXxx(deps)(une soixantaine :createBuild,createKart,createEnemies,createHud,createCoop…). Elle reçoit tout ce dont elle a besoin dans un objet de dépendances et renvoie une petite API.Almost every file insrc/v2exports acreateXxx(deps)factory (about sixty:createBuild,createKart,createEnemies,createHud,createCoop…). It takes everything it needs in a dependencies object and returns a small API. - Les modes ne s’importent pas entre eux et n’importent jamais
main.ts: c’estmain.tsqui crée chacun, lui passe la scène, la physique, les ennemis, le réseau, l’interface, et appelle leurupdate(dt)quand leur mode est actif. De l’injection de dépendances, à la main, sans conteneur.Modes never import each other and never importmain.ts: it ismain.tsthat creates each one, hands it the scene, physics, enemies, network and interface, and calls theirupdate(dt)when their mode is active. Dependency injection, by hand, no container. - Conséquence : on peut ajouter un mode (Balade, Construction) sans toucher aux autres, et le retirer d’une plateforme (la Construction reste sur le web) avec une seule condition dans
main.ts.Consequence: a mode (Stroll, Build) can be added without touching the others, and removed from one platform (Build stays web-only) with a single condition inmain.ts.
Pas d’ECS, pas de moteur physiqueNo ECS, no physics engine
- Les zombies, projectiles et villageois sont de simples objets dans des tableaux, mis à jour par leur module. Pour la collision,
physics.tsne connaît que trois choses : des segments de mur verticaux, des boîtes orientées et un sol. Des lancers de rayons servent aux balles, à la ligne de vue et à la caméra.Zombies, projectiles and villagers are plain objects in arrays, updated by their module. For collisions,physics.tsknows only three things: vertical wall segments, oriented boxes and a ground. Ray casts serve bullets, line of sight and the camera. - C’est volontaire : une physique de ce genre se comprend en une page, se déboggue à l’œil, et tient largement sur un téléphone d’entrée de gamme.It is deliberate: physics of this kind fits on one page, can be debugged by eye, and runs comfortably on an entry-level phone.
Rendu : du pixel-art dans un décor en volumeRendering: pixel art in a 3D set
- Les personnages et les zombies sont des sprites (feuilles de 64 × 64 px, trois rangées : face, profil, dos) posés dans une scène 3D ; les bâtiments, eux, sont de vrais volumes extrudés depuis les données cartographiques, texturés par des tuiles pixel-art.Characters and zombies are sprites (64 × 64 px sheets, three rows: front, side, back) set in a 3D scene; buildings are real volumes extruded from the map data, textured with pixel-art tiles.
- Ce qui se répète est instancié (
InstancedMesh) ou fusionné : une parcelle d’obstacles de 48 m = un seul maillage, donc un seul appel de dessin. Les lumières sont comptées : deuxPointLighten pool pour tous les feux du village, jamais une par flamme (chaque lumière de plus recompile les shaders).Anything repeated is instanced (InstancedMesh) or merged: a 48 m obstacle plot = one mesh, so one draw call. Lights are rationed: two pooledPointLights for every fire in the village, never one per flame (each extra light recompiles the shaders). - Un étalonnage final (tilt-shift, brume, crépuscule progressif) donne l’ambiance « maquette » ; les paliers de qualité s’adaptent à l’appareil (téléphone, PC, console portable).A final grading pass (tilt-shift, fog, progressive dusk) gives the “scale model” look; quality tiers adapt to the device (phone, PC, handheld).
Un monde déterministeA deterministic world
- Les obstacles de rue, la forêt de la Construction, les monuments, les chasses au trésor : tout est généré à partir d’une graine (la parcelle, la tuile, la ville). Deux joueurs qui chargent le même endroit obtiennent exactement le même monde, sans que le réseau ait à le transporter.Street obstacles, the Build forest, landmarks, treasure hunts: everything is generated from a seed (the plot, the tile, the city). Two players loading the same place get exactly the same world, with nothing for the network to carry.
- Ce qui change en cours de partie (un arbre scié, un bâtiment posé) voyage sous forme d’événements courts, numérotés identiquement chez tous.What changes during a match (a felled tree, a placed building) travels as short events, numbered identically for everyone.
Réseau : l’hôte fait autoritéNetworking: the host is the authority
- L’hôte (un joueur) simule tout ce qui compte : zombies, vagues, explosions, ramassables, économie de la Construction. Les invités publient leur propre état (≈ 12 fois par seconde), envoient leurs actions et reçoivent un instantané du monde (≈ 10 fois par seconde).The host (a player) simulates everything that matters: zombies, waves, explosions, pickups, the Build economy. Guests publish their own state (≈ 12 times a second), send their actions and receive a world snapshot (≈ 10 times a second).
- Trois couches derrière une seule interface :
link.ts(liaison directe WebRTC en étoile), au-dessus d’un transport Firebase (en ligne) ou d’un petit serveur WebSocket natif (Wi-Fi local, plugin CapacitorOwlLan, annonce Bonjour). Le jeu ne voit pas la différence.Three layers behind a single interface:link.ts(direct WebRTC link in a star), on top of a Firebase transport (online) or a small native WebSocket server (local Wi-Fi,OwlLanCapacitor plugin, Bonjour announcement). The game can’t tell the difference. - Pas de compte, pas de serveur à payer : un simple code de partie suffit.No account, no server to pay for: a simple game code is enough.
Langue, interface, commandesLanguage, interface, controls
- Le jeu est écrit en français ; l’anglais est une couche de traduction appliquée à l’affichage (DOM, textes dessinés sur canvas), à partir d’un dictionnaire généré par des scripts. Ajouter un texte ne demande donc aucun fichier de langue à la main.The game is written in French; English is a translation layer applied at display time (DOM, text drawn on canvas), from a dictionary generated by scripts. Adding a string needs no hand-edited language file.
- Menus et HUD sont du DOM, pas du 3D : ils se redimensionnent, se traduisent, et se partagent à l’écran partagé (une copie par joueur).Menus and HUD are DOM, not 3D: they resize, translate, and split for split-screen (one copy per player).
- Toutes les commandes passent par une couche d’abstraction (clavier, manettes Xbox / PlayStation / Switch, tactile) : le mode Construction se joue avec seulement deux boutons, ACTION et MENU, partout.All controls go through an abstraction layer (keyboard, Xbox / PlayStation / Switch pads, touch): Build mode is played with just two buttons, ACTION and MENU, everywhere.
Les dépendancesThe dependencies
Peu, et choisies : chaque dépendance est quelque chose qu’on ne voulait pas réécrire.Few, and chosen: each dependency is something we didn’t want to rewrite.
| ComposantComponent | À quoi il sertWhat it does |
|---|---|
| three | Le rendu 3D (WebGPU, repli WebGL), les matériaux en nœuds, l’instanciation.The 3D rendering (WebGPU, WebGL fallback), node materials, instancing. |
| TypeScript · Vite | Typage strict (le build échoue au moindre type faux) ; Vite construit les deux entrées et sert le jeu en développement.Strict typing (the build fails on the slightest wrong type); Vite builds both entries and serves the game in development. |
| Rust · wasm-pack | Le module citygen : extrusion des bâtiments, toits, collisions, occlusion de la caméra, sélection des plus proches. Compilé en WebAssembly, chargé comme un module.The citygen module: building extrusion, roofs, collisions, camera occlusion, nearest-building selection. Compiled to WebAssembly, loaded like a module. |
| firebase | Hébergement statique et base temps réel (salons de coopération). Importé par morceaux : seul ce qui sert est dans le paquet.Static hosting and realtime database (co-op lobbies). Imported piece by piece: only what is used ends up in the bundle. |
| Capacitor | La coque iOS / Android autour de la WebView, et nos deux plugins natifs (Swift, Java) pour la coop Wi-Fi.The iOS / Android shell around the WebView, and our two native plugins (Swift, Java) for Wi-Fi co-op. |
| capacitor-updater | Les mises à jour du contenu sans repasser par les magasins (voir ci-dessous), sans passer par un cloud tiers.Content updates without going through the stores again (see below), without a third-party cloud. |
| Electron | L’application de bureau Mac / Windows, avec ses villes et ses voix embarquées.The Mac / Windows desktop app, with its bundled cities and voices. |
| ws | Le relais WebSocket du Wi-Fi local, côté ordinateur.The local Wi-Fi WebSocket relay, on the computer side. |
| Polices | Teko (titres) et Silkscreen (pixels), embarquées : aucune requête vers un service de polices.Teko (titles) and Silkscreen (pixels), bundled: no request to a font service. |
| Gemini · Python | Hors du jeu : la fabrique d’images (sprites, textures, icônes) et de voix, pilotée par des scripts Python (Pillow, NumPy, SciPy) et bornée par un plafond de dépense.Outside the game: the factory of images (sprites, textures, icons) and voices, driven by Python scripts (Pillow, NumPy, SciPy) and bounded by a spending cap. |
Ce qui n’y est pas : pas de React ni de framework d’interface, pas de moteur de jeu, pas de moteur physique, pas de base de données à nous, pas de serveur de jeu, pas de bibliothèque audio (tout passe par Web Audio).What isn’t there: no React or UI framework, no game engine, no physics engine, no database of our own, no game server, no audio library (everything goes through Web Audio).
De la machine du développeur au joueurFrom the developer’s machine to the player
Le webThe web
npm run build: vérification de types, puis Vite, puis rangement des pages. Le résultat part sur Firebase Hosting (des fichiers statiques derrière un CDN), après purge des anciennes versions.npm run build: type check, then Vite, then page layout. The result goes to Firebase Hosting (static files behind a CDN), after pruning old versions.- Les fichiers de code ont un nom à empreinte, donc un cache éternel sans risque. Les images et sons posés tels quels dans
public/n’en ont pas : quand on en modifie un, on le versionne dans son adresse (?v=2), sinon les navigateurs gardent l’ancien un jour de plus. Une leçon apprise à nos dépens avec les arbres.Code files have a hashed name, hence a safe eternal cache. Images and sounds dropped as-is inpublic/don’t: when one changes, its address is versioned (?v=2), otherwise browsers keep the old one for another day. A lesson learned the hard way with the trees.
Les appsThe apps
- Un second build (
--mode app) ne garde que la v2, la rend autonome (hors ligne, coop Wi-Fi) et la copie dans les projets natifs. Android part en bundle pour le Play Store, iOS en TestFlight, le bureau en .dmg / .zip.A second build (--mode app) keeps only v2, makes it self-contained (offline, Wi-Fi co-op) and copies it into the native projects. Android ships as a bundle for the Play Store, iOS through TestFlight, desktop as .dmg / .zip. - Les mises à jour « OTA » : l’app lit un petit fichier
latest.jsonsur le site, ne télécharge que les fichiers modifiés, et applique la nouvelle version au lancement suivant, jamais en pleine partie. Si le jeu ne confirme pas qu’il a démarré, l’ancienne version revient seule.The “OTA” updates: the app reads a smalllatest.jsonon the site, downloads only the changed files, and applies the new version at the next launch, never mid-game. If the game doesn’t confirm it started, the previous version comes back on its own.
Comment on s’assure que ça marcheHow we make sure it works
- Des essais dans un vrai Chrome. Un petit banc pilote un navigateur avec fenêtre (protocole de débogage), joue des scénarios écrits en JavaScript contre le jeu en marche (poser un bâtiment, lancer une nuit, mesurer le rendement d’un cueilleur) et prend des captures. Chaque nouveauté est vérifiée en local, puis de nouveau en ligne après le déploiement.Tests in a real Chrome. A small bench drives a windowed browser (debugging protocol), plays scenarios written in JavaScript against the running game (place a building, start a night, measure a picker’s yield) and takes screenshots. Every new feature is checked locally, then again online after deployment.
- Un banc de performance (
perf_probe) qui mesure les images par seconde et le temps processeur sur des scènes de charge, pour chaque palier de qualité.A performance bench (perf_probe) that measures frames per second and CPU time on stress scenes, for every quality tier. - Un garde-fou contre les commentaires qui avalent du code. Un
// commentaireen fin de ligne peut avaler l’instruction qui suit quand le code est compacté ; ça nous est arrivé (des karts qui ne rechargeaient plus), d’où un script qui le traque avant chaque mise en ligne.A guard against comments that swallow code. A trailing// commentcan swallow the next statement when code is compacted; it happened to us (karts that stopped reloading), hence a script that hunts for it before every release. - Un plafond de dépense. Chaque appel à Gemini passe par un registre qui refuse de dépasser le budget : un script qui boucle ne peut pas vider le compte.A spending cap. Every Gemini call goes through a ledger that refuses to exceed the budget: a runaway script can’t drain the account.
Les pièges qu’on a fini par aimerThe pitfalls we grew fond of
- Le relief arrive en retard. Un décor posé avant que l’altitude IGN soit chargée se retrouve à 0 m, puis flotte une fois le relief arrivé : tout ce qui est posé au sol est donc reposé chaque seconde.Relief arrives late. A prop placed before the IGN elevation is loaded ends up at 0 m, then floats once the relief arrives: everything standing on the ground is therefore re-placed every second.
- Le sens des sprites. Une voiture dessinée « vers la droite » roule en marche arrière si une seule feuille est inversée : une règle unique pour toutes les feuilles, plus un script qui vérifie.Sprite direction. A car drawn “facing right” drives backwards if just one sheet is flipped: one single rule for every sheet, plus a script that checks.
- Générer des images par IA, c’est surtout du tri. Une planche de seize tuiles se découpe, se détoure sur son fond magenta, se relit à l’œil, et on garde la meilleure de chaque : l’atlas des bâtiments mêle des planches différentes tuile par tuile.AI image generation is mostly sorting. A sheet of sixteen tiles is cut, keyed out of its magenta background, reviewed by eye, and the best of each is kept: the building atlas mixes different sheets tile by tile.