OWL // DEAD STREETS devlog
JouerPlay

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.

Là où ça tourneWhere it runs
Navigateurle web : onlinewalkinglimulator.web.appthe webiOS · AndroidCapacitor : le même jeu dans une WebView, plus un plugin natif Wi-FiCapacitor: the same game in a WebView, plus a native Wi-Fi pluginMac · WindowsElectron : villes et voix embarquées, 100 % hors ligneElectron: cities and voices bundled, fully offlineSteaml’appli de bureau, empaquetée pour le magasinthe desktop app, packaged for the store
▼
Le jeu (un seul paquet)The game (one bundle)
v2 · Dead Streetsla page d’accueil : tous les modesthe home page: all the modesv1 · Explorationla première version, gardée sous /v1/the first version, kept under /v1/main.tschef d’orchestre : boucle de jeu, états, menus, branchement des modesconductor: game loop, states, menus, wiring of the modes
▼
Les modes de jeu (src/v2)The game modes (src/v2)
runLivraisonDeliveryhordeHordeHordeboss · skateBoss, PoursuiteBosses, Chaseriddle · ztevePère Casse-Tête, ZteveFather Riddle, Ztevekart*KartsKartsexploreBaladeStrollbuildConstructionBuild
▼
Les services partagésShared services
enemies · playerzombies, héros, projectileszombies, heroes, projectilesphysicsmurs, boîtes orientées, rayons : maisonwalls, oriented boxes, rays: home-madefx · audioparticules, sons, musiqueparticles, sound, musichud · menu2 · touchinterface DOM, manettes, tactileDOM interface, gamepads, touchi18nfrançais → anglais à l’affichageFrench → English at display time
▼
Le moteur de ville (src/)The city engine (src/)
worldd’où viennent les tuileswhere tiles come fromcity + citygenbâtiments extrudés (Rust / WASM)extruded buildings (Rust / WASM)ground · terrainsol en photo aérienne, reliefaerial-photo ground, relieftrees · traffic · npcvégétation, circulation, passantsvegetation, traffic, pedestrianstilecache · geopfcache et file d’appels vers l’IGNcache and call queue to the IGN
▼
Données et services en ligneData and online services
cities/15 villes précalculées (cartes + photos)15 precomputed cities (maps + photos)IGN Géoplateformen’importe quelle adresse de France, en directany address in France, liveFirebase Hostingun CDN de fichiers statiques : pas de serveur applicatifa static-file CDN: no application serverRealtime Databasesalons de coop, signalisationco-op lobbies, signalling

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.html et v2.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 in src/ and only differ by their entry point (index.html and v2.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/v2 exporte une fabrique createXxx(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 in src/v2 exports a createXxx(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’est main.ts qui crée chacun, lui passe la scène, la physique, les ennemis, le réseau, l’interface, et appelle leur update(dt) quand leur mode est actif. De l’injection de dépendances, à la main, sans conteneur.Modes never import each other and never import main.ts: it is main.ts that creates each one, hands it the scene, physics, enemies, network and interface, and calls their update(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 in main.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.ts ne 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.ts knows 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 : deux PointLight en 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 pooled PointLights 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 Capacitor OwlLan, 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, OwlLan Capacitor 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
threeLe rendu 3D (WebGPU, repli WebGL), les matériaux en nœuds, l’instanciation.The 3D rendering (WebGPU, WebGL fallback), node materials, instancing.
TypeScript · ViteTypage 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-packLe 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.
firebaseHé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.
CapacitorLa 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-updaterLes 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.
ElectronL’application de bureau Mac / Windows, avec ses villes et ses voix embarquées.The Mac / Windows desktop app, with its bundled cities and voices.
wsLe relais WebSocket du Wi-Fi local, côté ordinateur.The local Wi-Fi WebSocket relay, on the computer side.
PolicesTeko (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 · PythonHors 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 in public/ 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.json sur 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 small latest.json on 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

Les pièges qu’on a fini par aimerThe pitfalls we grew fond of