Explorar o código

feat(net): LAN discovery, lobby with ready check, match start, hash check (M3)

- Server announces itself over UDP (port 47101) every second on every IPv4
  subnet broadcast, the limited broadcast and loopback; the main process
  listens and pushes the list of games to the renderer.
- Game browser screen: games on the LAN (version and capacity checked),
  create a game, or join by address.
- Lobby phase: players warm up on the map and mark themselves ready; when
  all are, a 3 s countdown starts a match on a freshly loaded map. Player
  ids survive across matches; an empty server goes back to the lobby.
- Every 5 s the server sends its bitmap hash at the current event seq;
  clients compare once they reach that seq and report a desync, which the
  server logs.
- Renderer: the world can be dropped and reloaded on match start.
- Protocol v4. Server reports a clear error when its port is taken.
- Tests: protocol version rejection, lobby/ready/countdown/start with two
  real WebSocket clients replaying the event stream (hashes match), player
  leave, and UDP discovery end to end on the local machine.

No new dependency (dgram and os are Node built-ins).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Idleman hai 1 semana
pai
achega
699e8b7ce1

+ 1 - 0
readme.md

@@ -288,6 +288,7 @@ Chaque étape doit être **jouable** avant de passer à la suivante.
 - Inputs vers le serveur, snapshots et ops vers les clients, interpolation.
 - Test de cohérence : hash du bitmap comparé entre serveur et clients toutes les 5 secondes, avec un log d'erreur en cas d'écart.
 - **Terminé quand :** 4 PC en LAN creusent le même gâteau sans aucune différence.
+- **État :** implémenté. Le serveur s'annonce sur chaque sous-réseau et en local. L'écran « Les goûters du coin » liste les parties, permet d'en créer une ou d'en rejoindre une par adresse. Pendant l'échauffement, chacun se déclare prêt ; quand tout le monde l'est, un compte à rebours de 3 s lance la partie sur une carte neuve. Le hash est vérifié toutes les 5 s et un écart est loggé côté serveur. Testé avec plusieurs clients en tests automatiques et avec 2 instances sur le même PC. **Reste à valider sur 4 vrais PC** (pare-feu Windows à autoriser au premier lancement).
 ### M4 : Règles de jeu
  
 - Cerise, score, crème qui monte, cuillère de réapparition, objets, manches, partie, écran de score.

+ 67 - 0
src/main/discovery.ts

@@ -0,0 +1,67 @@
+import { createSocket, type Socket } from 'node:dgram';
+import { DISCOVERY_PORT, DISCOVERY_TTL_MS } from '../shared/constants';
+import { isAnnouncement, type Announcement } from '../shared/protocol';
+import type { LanGame } from '../preload/api';
+
+interface Seen {
+  announcement: Announcement;
+  address: string;
+  lastSeen: number;
+}
+
+/** Listens for UDP announcements and keeps the list of games currently visible on the LAN. */
+export class DiscoveryListener {
+  private socket: Socket | null = null;
+  private readonly games = new Map<string, Seen>();
+
+  start(onError: (err: Error) => void): void {
+    if (this.socket) return;
+    const socket = createSocket({ type: 'udp4', reuseAddr: true });
+    socket.on('error', onError);
+    socket.on('message', (data, rinfo) => {
+      let msg: unknown;
+      try {
+        msg = JSON.parse(data.toString('utf8'));
+      } catch {
+        return;
+      }
+      if (!isAnnouncement(msg)) return;
+      const known = this.games.get(msg.serverId);
+      // The same server is heard on loopback and on the LAN: keep the LAN address when we have one.
+      const address = known && rinfo.address === '127.0.0.1' && known.address !== '127.0.0.1' ? known.address : rinfo.address;
+      this.games.set(msg.serverId, { announcement: msg, address, lastSeen: Date.now() });
+    });
+    socket.bind(DISCOVERY_PORT);
+    this.socket = socket;
+  }
+
+  stop(): void {
+    this.socket?.close();
+    this.socket = null;
+    this.games.clear();
+  }
+
+  list(): LanGame[] {
+    const now = Date.now();
+    const out: LanGame[] = [];
+    for (const [id, seen] of this.games) {
+      if (now - seen.lastSeen > DISCOVERY_TTL_MS) {
+        this.games.delete(id);
+        continue;
+      }
+      const a = seen.announcement;
+      out.push({
+        serverId: a.serverId,
+        name: a.name,
+        mapId: a.mapId,
+        players: a.players,
+        maxPlayers: a.maxPlayers,
+        phase: a.phase,
+        version: a.version,
+        address: seen.address,
+        port: a.port,
+      });
+    }
+    return out.sort((x, y) => x.name.localeCompare(y.name));
+  }
+}

+ 28 - 4
src/main/index.ts

@@ -1,8 +1,10 @@
 import { join } from 'node:path';
-import { app, BrowserWindow, ipcMain, utilityProcess, type UtilityProcess } from 'electron';
+import { app, BrowserWindow, ipcMain, utilityProcess, type UtilityProcess, type WebContents } from 'electron';
 import { readFileSync } from 'node:fs';
 import type { HostResult, MapFiles } from '../preload/api';
+import { DISCOVERY_INTERVAL_MS } from '../shared/constants';
 import { readMapFiles, readMapImage, resolveAssetsDir } from '../server/assets';
+import { DiscoveryListener } from './discovery';
 
 const assetsDir = app.isPackaged ? join(process.resourcesPath, 'assets') : join(app.getAppPath(), 'assets');
 process.env.GOUTER_ASSETS = assetsDir;
@@ -11,10 +13,10 @@ let server: UtilityProcess | null = null;
 let serverReady: Promise<HostResult> | null = null;
 
 /** Forks the authoritative server in a utilityProcess (once) and waits for it to listen. */
-function hostServer(): Promise<HostResult> {
+function hostServer(name: string): Promise<HostResult> {
   if (serverReady) return serverReady;
   serverReady = new Promise<HostResult>((resolveReady, reject) => {
-    const proc = utilityProcess.fork(join(__dirname, 'server.js'), [], {
+    const proc = utilityProcess.fork(join(__dirname, 'server.js'), [`--name=${String(name).slice(0, 40)}`], {
       serviceName: 'goutermageddon-server',
       env: { ...process.env, GOUTER_ASSETS: assetsDir },
       stdio: 'inherit',
@@ -51,7 +53,28 @@ function createWindow(): void {
   else void win.loadFile(join(__dirname, '../renderer/index.html'));
 }
 
-ipcMain.handle('lan:host', () => hostServer());
+// LAN discovery: one listener for the app, pushed to the renderer that asked for it.
+const discovery = new DiscoveryListener();
+let discoveryTimer: NodeJS.Timeout | null = null;
+function startDiscovery(target: WebContents): void {
+  stopDiscovery();
+  discovery.start((err) => console.error('[discovery] cannot listen:', err.message));
+  const push = (): void => {
+    if (target.isDestroyed()) return stopDiscovery();
+    target.send('lan:games', discovery.list());
+  };
+  push();
+  discoveryTimer = setInterval(push, DISCOVERY_INTERVAL_MS);
+}
+function stopDiscovery(): void {
+  if (discoveryTimer) clearInterval(discoveryTimer);
+  discoveryTimer = null;
+  discovery.stop();
+}
+
+ipcMain.handle('lan:host', (_event, name: string) => hostServer(name));
+ipcMain.handle('lan:discover-start', (event) => startDiscovery(event.sender));
+ipcMain.handle('lan:discover-stop', () => stopDiscovery());
 ipcMain.handle('assets:materials', () => JSON.parse(readFileSync(join(resolveAssetsDir(), 'materials', 'materials.json'), 'utf8')));
 ipcMain.handle('assets:map', (_event, mapId: string): MapFiles => {
   const dir = resolveAssetsDir();
@@ -67,6 +90,7 @@ void app.whenReady().then(() => {
 });
 
 app.on('window-all-closed', () => {
+  stopDiscovery();
   server?.kill();
   app.quit();
 });

+ 18 - 2
src/preload/api.ts

@@ -1,5 +1,6 @@
 // Typed API exposed to the renderer by the preload script. Type-only: shared by preload and renderer.
 import type { RgbaImage } from '../shared/maps/loader';
+import type { Phase } from '../shared/protocol';
 
 export interface MapFiles {
   /** Raw map.json, validated by the renderer with shared/maps/loader. */
@@ -13,9 +14,24 @@ export interface HostResult {
   port: number;
 }
 
+/** A game seen on the LAN. */
+export interface LanGame {
+  serverId: string;
+  name: string;
+  mapId: string;
+  players: number;
+  maxPlayers: number;
+  phase: Phase;
+  version: number;
+  address: string;
+  port: number;
+}
+
 export interface LanApi {
-  /** Starts the local authoritative server and resolves once it listens. */
-  host(): Promise<HostResult>;
+  /** Starts the local authoritative server (once) and resolves once it listens. */
+  host(name: string): Promise<HostResult>;
+  /** Starts listening for LAN games; `onGames` receives the full list about once per second. Returns a stop function. */
+  discover(onGames: (games: LanGame[]) => void): () => void;
 }
 
 export interface AssetsApi {

+ 12 - 3
src/preload/index.ts

@@ -1,8 +1,17 @@
-import { contextBridge, ipcRenderer } from 'electron';
-import type { AssetsApi, LanApi } from './api';
+import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron';
+import type { AssetsApi, LanApi, LanGame } from './api';
 
 const lan: LanApi = {
-  host: () => ipcRenderer.invoke('lan:host'),
+  host: (name) => ipcRenderer.invoke('lan:host', name),
+  discover: (onGames) => {
+    const listener = (_event: IpcRendererEvent, games: LanGame[]): void => onGames(games);
+    ipcRenderer.on('lan:games', listener);
+    void ipcRenderer.invoke('lan:discover-start');
+    return () => {
+      ipcRenderer.removeListener('lan:games', listener);
+      void ipcRenderer.invoke('lan:discover-stop');
+    };
+  },
 };
 
 const assets: AssetsApi = {

+ 24 - 0
src/renderer/i18n/fr.ts

@@ -18,6 +18,30 @@ export const fr = {
     castor: 'Q/D : courir · Espace/Z : sauter · Clic gauche : coup de dents · Clic droit (maintenu) : manger le décor',
   } as Record<string, string>,
   fps: (n: number) => `${n} FPS`,
+  browser: {
+    title: 'Les goûters du coin',
+    searching: 'On renifle le réseau… Aucune partie pour l’instant.',
+    host: 'Créer une partie',
+    join: 'Rejoindre',
+    back: 'Retour',
+    players: (n: number, max: number) => `${n}/${max} gourmands`,
+    phase: { lobby: 'à l’échauffement', playing: 'en pleine baston' } as Record<string, string>,
+    incompatible: 'version différente',
+    full: 'complet',
+    manualLabel: 'Ou par adresse :',
+    manualPlaceholder: '192.168.1.42',
+    gameName: (name: string) => `Goûter de ${name}`,
+  },
+  lobby: {
+    title: 'Échauffement',
+    hint: 'Croquez le décor en attendant tout le monde !',
+    ready: 'Je suis prêt !',
+    notReady: 'Attends, pas encore !',
+    countdown: (n: number) => `Ça commence dans ${n}…`,
+    waiting: (n: number) => `En attente de ${n} gourmand${n > 1 ? 's' : ''}`,
+    started: 'C’est parti !',
+  },
+  desync: 'Oups, ton gâteau ne ressemble plus à celui du serveur (désynchronisation).',
   defaultName: 'Gourmand',
   characters: {
     generic: { name: 'Le Testeur', desc: 'Court, saute, croque. Rien de plus, rien de moins.' },

+ 104 - 30
src/renderer/main.ts

@@ -4,7 +4,7 @@ import { Anim, CHARACTER_IDS } from '../shared/characters';
 import { DEFAULT_CHARACTER_ID, INTERPOLATION_DELAY_MS, PROTOCOL_VERSION } from '../shared/constants';
 import { buildTerrain, parseMapDefinition } from '../shared/maps/loader';
 import { parseHexColor, parseMaterials, type MaterialRegistry } from '../shared/materials';
-import { hello, type ChunkState, type PlayerInfo, type PlayerState, type ServerMessage } from '../shared/protocol';
+import { hello, type ChunkInfo, type ChunkState, type PlayerInfo, type PlayerState, type ServerMessage } from '../shared/protocol';
 import type { TerrainBitmap } from '../shared/terrain/bitmap';
 import { applyTerrainEvent, type SequencedEvent, type TerrainEvent } from '../shared/terrain/events';
 import { rleDecode } from '../shared/terrain/rle';
@@ -35,7 +35,9 @@ import { InputTracker } from './input';
 import { Connection } from './net/connection';
 import { SnapshotBuffer } from './net/interpolation';
 import { TerrainView } from './terrain/TerrainView';
-import { showMenu } from './ui/menu';
+import { showBrowser } from './ui/browser';
+import { LobbyPanel } from './ui/lobbyPanel';
+import { showMenu, type MenuChoice } from './ui/menu';
 
 const PORTRAIT_RADIUS = 34;
 const EATING_POPUP_RANGE = 120;
@@ -47,24 +49,22 @@ const setStatus = (text: string): void => {
   statusEl.textContent = text;
 };
 
+/** Everything tied to one loaded map. Rebuilt when a match starts. */
 interface World {
   materials: MaterialRegistry;
   terrain: TerrainBitmap;
   view: TerrainView;
+  background: Sprite;
   lastSeq: number;
 }
 
-async function loadWorld(mapId: string, materials: MaterialRegistry, root: Container): Promise<World> {
+async function loadWorld(mapId: string, materials: MaterialRegistry): Promise<World> {
   const files = await window.assets.map(mapId);
   const def = parseMapDefinition(files.definition, mapId);
   const terrain = buildTerrain(def, files.terrain, materials);
-
   const bitmap = await createImageBitmap(new Blob([files.background as BlobPart], { type: 'image/png' }));
-  root.addChild(new Sprite(new Texture({ source: new ImageSource({ resource: bitmap }) })));
-
-  const view = new TerrainView(terrain, materials);
-  root.addChild(view.container);
-  return { materials, terrain, view, lastSeq: 0 };
+  const background = new Sprite(new Texture({ source: new ImageSource({ resource: bitmap }) }));
+  return { materials, terrain, view: new TerrainView(terrain, materials), background, lastSeq: 0 };
 }
 
 /** Screen-space warm vignette drawn over the game. */
@@ -94,14 +94,29 @@ function makePortraits(app: Application): Map<string, HTMLCanvasElement> {
   return out;
 }
 
+/** Title menu then game browser, until the player picks a game to host or join. */
+async function chooseGame(app: Application): Promise<{ choice: MenuChoice; address: string; port: number }> {
+  const portraits = makePortraits(app);
+  for (;;) {
+    const choice = await showMenu(CHARACTER_IDS, DEFAULT_CHARACTER_ID, portraits);
+    const target = await showBrowser();
+    if (target.mode === 'back') continue;
+    if (target.mode === 'join') return { choice, address: target.address, port: target.port };
+    setStatus(fr.connecting);
+    const { port } = await window.lan.host(fr.browser.gameName(choice.name));
+    return { choice, address: '127.0.0.1', port };
+  }
+}
+
 async function start(): Promise<void> {
   const app = new Application();
   const host = document.getElementById('game')!;
   await app.init({ resizeTo: window, background: 0xfff1d6, antialias: true });
   host.appendChild(app.canvas);
 
-  const choice = await showMenu(CHARACTER_IDS, DEFAULT_CHARACTER_ID, makePortraits(app));
+  const { choice, address, port } = await chooseGame(app);
   helpEl.textContent = fr.help[choice.characterId] ?? '';
+  setStatus(fr.connecting);
 
   const camera = new Container();
   const worldRoot = new Container();
@@ -112,18 +127,22 @@ async function start(): Promise<void> {
   const popups = new Popups();
   const playersLayer = new Container();
   const chunksLayer = new Container();
+  // Map-independent layers always sit above the map ones (background, terrain).
+  worldRoot.addChild(chunksLayer, playersLayer, particles.graphics, popups.container);
 
-  setStatus(fr.connecting);
-  const { port } = await window.lan.host();
   const materials = parseMaterials(await window.assets.materials());
 
   let world: World | null = null;
+  /** Bumped on every map (re)load, so a slow load never installs a stale world. */
+  let generation = 0;
   let localId = -1;
   let pendingEvents: SequencedEvent[] = [];
-  const infos = new Map<number, PlayerInfo>();
+  /** Server hash waiting for this client to reach the same event seq. */
+  let pendingHash: { seq: number; hash: number } | null = null;
+  let infos = new Map<number, PlayerInfo>();
   const views = new Map<number, PlayerView>();
   const chunkViews = new Map<number, ChunkView>();
-  const snapshots = new SnapshotBuffer();
+  let snapshots = new SnapshotBuffer();
   const input = new InputTracker(app.canvas);
   let states = new Map<number, PlayerState>();
   let shake = 0;
@@ -160,6 +179,18 @@ async function start(): Promise<void> {
     return m ? parseHexColor(m.color) : 0xffffff;
   };
 
+  /** Compares our bitmap with the server's once we have applied the same events (readme §9, M3). */
+  const checkHash = (): void => {
+    if (!world || !pendingHash || world.lastSeq !== pendingHash.seq) return;
+    const mine = world.terrain.hash();
+    if (mine !== pendingHash.hash) {
+      console.error(`[net] DESYNC at seq ${pendingHash.seq}: server ${pendingHash.hash}, client ${mine}`);
+      setStatus(fr.desync);
+      conn.send({ t: 'desync', seq: pendingHash.seq, hash: mine });
+    }
+    pendingHash = null;
+  };
+
   /**
    * Applies one terrain event in sequence. `live` is false while replaying history:
    * no effects, and no chunk sprites (chunks still falling come from the welcome message).
@@ -173,6 +204,7 @@ async function start(): Promise<void> {
     world.lastSeq = seq;
     const { removedByMaterial } = applyTerrainEvent(world.terrain, ev, world.materials);
     world.view.onEvent(ev);
+    checkHash();
 
     if (ev.kind === 'op') {
       if (!live) return;
@@ -206,24 +238,59 @@ async function start(): Promise<void> {
     popups.spawn(ev.x + ev.w / 2, ev.y, fr.onomatopoeia.thud, 30);
   };
 
+  /** Drops the current map and everything on it, then loads `mapId` and replays `history`. */
+  const resetWorld = (mapId: string, history: SequencedEvent[], chunks: ChunkInfo[]): void => {
+    const gen = ++generation;
+    if (world) {
+      world.background.destroy({ texture: true, textureSource: true });
+      world.view.container.destroy({ children: true, texture: true, textureSource: true });
+    }
+    world = null;
+    pendingEvents = [];
+    pendingHash = null;
+    for (const id of [...chunkViews.keys()]) removeChunk(id);
+    for (const view of views.values()) view.destroy();
+    views.clear();
+    snapshots = new SnapshotBuffer();
+    setStatus(fr.loadingMap);
+    void loadWorld(mapId, materials)
+      .then((w) => {
+        if (gen !== generation) return; // superseded by a newer start
+        world = w;
+        worldRoot.addChildAt(w.background, 0);
+        worldRoot.addChildAt(w.view.container, 1);
+        for (const { seq, ev } of history) applyServerEvent(seq, ev, false);
+        for (const c of chunks) addChunk(c.id, rleDecode(c.mask, c.w * c.h), c.w, c.h, c.state);
+        const live = pendingEvents;
+        pendingEvents = [];
+        for (const { seq, ev } of live) applyServerEvent(seq, ev, true);
+        setStatus('');
+      })
+      .catch((err: unknown) => setStatus(fr.loadError(err instanceof Error ? err.message : String(err))));
+  };
+
+  const lobbyPanel = new LobbyPanel(
+    () => localId,
+    (ready) => conn.send({ t: 'ready', ready }),
+  );
+
   const onMessage = (msg: ServerMessage): void => {
     switch (msg.t) {
       case 'welcome':
         localId = msg.playerId;
-        msg.players.forEach((p) => infos.set(p.id, p));
-        setStatus(fr.loadingMap);
-        void loadWorld(msg.mapId, materials, worldRoot)
-          .then((w) => {
-            world = w;
-            worldRoot.addChild(chunksLayer, playersLayer, particles.graphics, popups.container);
-            for (const { seq, ev } of msg.events) applyServerEvent(seq, ev, false);
-            for (const c of msg.chunks) addChunk(c.id, rleDecode(c.mask, c.w * c.h), c.w, c.h, c.state);
-            const live = pendingEvents;
-            pendingEvents = [];
-            for (const { seq, ev } of live) applyServerEvent(seq, ev, true);
-            setStatus('');
-          })
-          .catch((err: unknown) => setStatus(fr.loadError(err instanceof Error ? err.message : String(err))));
+        infos = new Map(msg.players.map((p) => [p.id, p]));
+        resetWorld(msg.mapId, msg.events, msg.chunks);
+        break;
+      case 'start':
+        infos = new Map(msg.players.map((p) => [p.id, p]));
+        resetWorld(msg.mapId, [], []);
+        setStatus(fr.lobby.started);
+        setTimeout(() => {
+          if (statusEl.textContent === fr.lobby.started) setStatus('');
+        }, 1500);
+        break;
+      case 'lobby':
+        lobbyPanel.update(msg.phase, msg.players, msg.countdown);
         break;
       case 'reject':
         setStatus(
@@ -245,6 +312,10 @@ async function start(): Promise<void> {
       case 'terrain':
         applyServerEvent(msg.seq, msg.ev, true);
         break;
+      case 'hash':
+        pendingHash = { seq: msg.seq, hash: msg.hash };
+        checkHash();
+        break;
       case 'chunkRemoved':
         removeChunk(msg.id);
         break;
@@ -261,10 +332,13 @@ async function start(): Promise<void> {
   };
 
   const conn = new Connection(
-    `ws://127.0.0.1:${port}`,
+    `ws://${address}:${port}`,
     onMessage,
     () => conn.send(hello(choice.name, choice.characterId)),
-    () => setStatus(fr.disconnected),
+    () => {
+      setStatus(fr.disconnected);
+      lobbyPanel.update('playing', [], null); // hide it
+    },
   );
 
   const screenToWorld = (sx: number, sy: number): [number, number] => [

+ 179 - 0
src/renderer/style.css

@@ -212,3 +212,182 @@ body {
   transform: translate(3px, 3px);
   box-shadow: 2px 2px 0 var(--ink);
 }
+
+/* ---------- Buttons ---------- */
+
+button.primary,
+button.secondary {
+  font: inherit;
+  font-weight: bold;
+  border: 3px solid var(--ink);
+  border-radius: 16px 11px 17px 10px;
+  padding: 6px 18px;
+  cursor: pointer;
+  box-shadow: 3px 3px 0 var(--ink);
+}
+
+button.primary {
+  color: #fff;
+  background: var(--pink);
+  text-shadow: 1px 1px 0 var(--ink);
+}
+
+button.secondary {
+  color: var(--ink);
+  background: #fff;
+}
+
+button.primary:hover:not(:disabled),
+button.secondary:hover:not(:disabled) {
+  transform: rotate(-1.5deg) scale(1.04);
+}
+
+button:disabled {
+  opacity: 0.45;
+  cursor: not-allowed;
+}
+
+button.small {
+  font-size: 14px;
+  padding: 4px 12px;
+}
+
+/* ---------- Game browser ---------- */
+
+.menu-card h1.small {
+  font-size: clamp(26px, 4.5vw, 40px);
+  margin-bottom: 14px;
+}
+
+.games {
+  list-style: none;
+  margin: 0 auto;
+  padding: 0;
+  max-height: 46vh;
+  overflow-y: auto;
+  text-align: left;
+}
+
+.games li {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  background: #fff;
+  border: 3px solid var(--ink);
+  border-radius: 14px 10px 15px 9px;
+  padding: 8px 12px;
+  margin-bottom: 10px;
+}
+
+.games li div {
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+}
+
+.games li strong {
+  font-size: 18px;
+}
+
+.games li span {
+  font-size: 13px;
+  opacity: 0.8;
+}
+
+.games li.empty {
+  justify-content: center;
+  font-style: italic;
+  border-style: dashed;
+}
+
+.manual {
+  display: flex;
+  gap: 10px;
+  justify-content: center;
+  align-items: center;
+  flex-wrap: wrap;
+  margin: 12px 0 4px;
+}
+
+.manual input {
+  font: inherit;
+  border: 3px solid var(--ink);
+  border-radius: 10px 8px 11px 7px;
+  padding: 4px 10px;
+  width: 170px;
+  margin-left: 8px;
+  color: var(--ink);
+}
+
+input.invalid {
+  border-color: #d93a5b !important;
+  background: #ffe3ea;
+}
+
+.actions {
+  display: flex;
+  justify-content: space-between;
+  margin-top: 18px;
+  gap: 12px;
+}
+
+/* ---------- Warm-up panel ---------- */
+
+#lobby-panel {
+  position: fixed;
+  top: 40px;
+  right: 16px;
+  width: 230px;
+  background: var(--paper);
+  border: 3px solid var(--ink);
+  border-radius: 18px 12px 20px 11px;
+  box-shadow: 5px 5px 0 rgba(59, 36, 20, 0.4);
+  padding: 10px 14px 14px;
+  z-index: 5;
+  text-align: center;
+}
+
+#lobby-panel[hidden] {
+  display: none;
+}
+
+#lobby-panel h3 {
+  margin: 0 0 2px;
+  color: var(--pink);
+  -webkit-text-stroke: 1px var(--ink);
+  font-size: 22px;
+}
+
+#lobby-panel .hint {
+  margin: 0 0 8px;
+  font-size: 12px;
+}
+
+#lobby-panel ul {
+  list-style: none;
+  margin: 0 0 6px;
+  padding: 0;
+  text-align: left;
+}
+
+#lobby-panel li {
+  padding: 2px 0;
+  opacity: 0.7;
+}
+
+#lobby-panel li.ready {
+  opacity: 1;
+  font-weight: bold;
+  color: #3f8a1f;
+}
+
+#lobby-panel li.me::after {
+  content: ' ★';
+  color: var(--pink);
+}
+
+#lobby-panel .status {
+  margin: 4px 0 10px;
+  font-weight: bold;
+}

+ 105 - 0
src/renderer/ui/browser.ts

@@ -0,0 +1,105 @@
+import { MAX_PLAYERS, NET_PORT, PROTOCOL_VERSION } from '../../shared/constants';
+import type { LanGame } from '../../preload/api';
+import { fr } from '../i18n/fr';
+
+export type BrowserChoice = { mode: 'host' } | { mode: 'join'; address: string; port: number } | { mode: 'back' };
+
+const ADDRESS_KEY = 'gouter.address';
+
+/** Parses "host" or "host:port". */
+function parseAddress(text: string): { address: string; port: number } | null {
+  const m = /^\s*([\w.-]+)(?::(\d{1,5}))?\s*$/.exec(text);
+  if (!m) return null;
+  return { address: m[1], port: m[2] ? Number(m[2]) : NET_PORT };
+}
+
+/** Lists the games announced on the LAN, plus "create" and "join by address". */
+export function showBrowser(): Promise<BrowserChoice> {
+  const root = document.createElement('div');
+  root.id = 'menu';
+  root.innerHTML = `
+    <div class="menu-card browser">
+      <h1 class="small">${fr.browser.title}</h1>
+      <ul class="games"></ul>
+      <div class="manual">
+        <label>${fr.browser.manualLabel}
+          <input id="browser-address" placeholder="${fr.browser.manualPlaceholder}" autocomplete="off" spellcheck="false" />
+        </label>
+        <button id="browser-manual" class="secondary">${fr.browser.join}</button>
+      </div>
+      <div class="actions">
+        <button id="browser-back" class="secondary">${fr.browser.back}</button>
+        <button id="browser-host" class="primary">${fr.browser.host}</button>
+      </div>
+    </div>`;
+  document.body.appendChild(root);
+
+  const list = root.querySelector<HTMLUListElement>('.games')!;
+  const addressInput = root.querySelector<HTMLInputElement>('#browser-address')!;
+  try {
+    addressInput.value = localStorage.getItem(ADDRESS_KEY) ?? '';
+  } catch {
+    // Storage unavailable: start empty.
+  }
+
+  let resolve!: (choice: BrowserChoice) => void;
+  const done = new Promise<BrowserChoice>((r) => (resolve = r));
+  const finish = (choice: BrowserChoice): void => {
+    stop();
+    root.remove();
+    resolve(choice);
+  };
+
+  const render = (games: LanGame[]): void => {
+    list.replaceChildren();
+    if (games.length === 0) {
+      const li = document.createElement('li');
+      li.className = 'empty';
+      li.textContent = fr.browser.searching;
+      list.appendChild(li);
+      return;
+    }
+    for (const g of games) {
+      const li = document.createElement('li');
+      const info = document.createElement('div');
+      const name = document.createElement('strong');
+      name.textContent = g.name;
+      const details = document.createElement('span');
+      details.textContent = `${fr.browser.players(g.players, g.maxPlayers ?? MAX_PLAYERS)} · ${fr.browser.phase[g.phase] ?? g.phase} · ${g.address}`;
+      info.append(name, details);
+      const button = document.createElement('button');
+      button.className = 'primary small';
+      const compatible = g.version === PROTOCOL_VERSION;
+      const full = g.players >= g.maxPlayers;
+      button.textContent = !compatible ? fr.browser.incompatible : full ? fr.browser.full : fr.browser.join;
+      button.disabled = !compatible || full;
+      button.addEventListener('click', () => finish({ mode: 'join', address: g.address, port: g.port }));
+      li.append(info, button);
+      list.appendChild(li);
+    }
+  };
+  render([]);
+  const stop = window.lan.discover(render);
+
+  root.querySelector('#browser-host')!.addEventListener('click', () => finish({ mode: 'host' }));
+  root.querySelector('#browser-back')!.addEventListener('click', () => finish({ mode: 'back' }));
+  const joinManual = (): void => {
+    const parsed = parseAddress(addressInput.value);
+    if (!parsed) {
+      addressInput.classList.add('invalid');
+      return;
+    }
+    try {
+      localStorage.setItem(ADDRESS_KEY, addressInput.value.trim());
+    } catch {
+      // Not remembered, that's all.
+    }
+    finish({ mode: 'join', ...parsed });
+  };
+  root.querySelector('#browser-manual')!.addEventListener('click', joinManual);
+  addressInput.addEventListener('keydown', (e) => {
+    addressInput.classList.remove('invalid');
+    if (e.key === 'Enter') joinManual();
+  });
+  return done;
+}

+ 48 - 0
src/renderer/ui/lobbyPanel.ts

@@ -0,0 +1,48 @@
+import type { LobbyPlayer, Phase } from '../../shared/protocol';
+import { fr } from '../i18n/fr';
+
+/** Warm-up panel: who is here, who is ready, and the start countdown. Hidden during a match. */
+export class LobbyPanel {
+  private readonly root: HTMLDivElement;
+  private readonly list: HTMLUListElement;
+  private readonly status: HTMLParagraphElement;
+  private readonly button: HTMLButtonElement;
+  private ready = false;
+
+  constructor(
+    private readonly localId: () => number,
+    onReady: (ready: boolean) => void,
+  ) {
+    this.root = document.createElement('div');
+    this.root.id = 'lobby-panel';
+    this.root.innerHTML = `<h3>${fr.lobby.title}</h3><p class="hint">${fr.lobby.hint}</p><ul></ul><p class="status"></p><button class="primary"></button>`;
+    this.list = this.root.querySelector('ul')!;
+    this.status = this.root.querySelector('.status')!;
+    this.button = this.root.querySelector('button')!;
+    this.button.addEventListener('click', () => {
+      onReady(!this.ready);
+      this.button.blur(); // keep Space for jumping, not for re-clicking
+    });
+    this.root.hidden = true;
+    document.body.appendChild(this.root);
+  }
+
+  update(phase: Phase, players: LobbyPlayer[], countdown: number | null): void {
+    this.root.hidden = phase !== 'lobby';
+    if (phase !== 'lobby') return;
+    this.ready = players.find((p) => p.id === this.localId())?.ready ?? false;
+    this.button.textContent = this.ready ? fr.lobby.notReady : fr.lobby.ready;
+    this.button.classList.toggle('secondary', this.ready);
+    this.list.replaceChildren(
+      ...players.map((p) => {
+        const li = document.createElement('li');
+        li.className = p.ready ? 'ready' : '';
+        li.textContent = `${p.ready ? '✔' : '…'} ${p.name}`;
+        if (p.id === this.localId()) li.classList.add('me');
+        return li;
+      }),
+    );
+    const waiting = players.filter((p) => !p.ready).length;
+    this.status.textContent = countdown !== null ? fr.lobby.countdown(countdown) : fr.lobby.waiting(waiting);
+  }
+}

+ 56 - 0
src/server/discovery.ts

@@ -0,0 +1,56 @@
+import { createSocket, type Socket } from 'node:dgram';
+import { networkInterfaces } from 'node:os';
+import { DISCOVERY_INTERVAL_MS, DISCOVERY_PORT } from '../shared/constants';
+import type { Announcement } from '../shared/protocol';
+
+/**
+ * Broadcast addresses to announce on: the subnet broadcast of every IPv4 interface
+ * (Windows sends 255.255.255.255 out of a single interface only), the limited broadcast,
+ * and loopback so a game is always visible on the machine hosting it.
+ */
+export function broadcastAddresses(): string[] {
+  const out = new Set<string>(['255.255.255.255', '127.0.0.1']);
+  for (const list of Object.values(networkInterfaces())) {
+    for (const ni of list ?? []) {
+      if (ni.family !== 'IPv4' || ni.internal) continue;
+      const ip = ni.address.split('.').map(Number);
+      const mask = ni.netmask.split('.').map(Number);
+      out.add(ip.map((b, i) => (b | (~mask[i] & 0xff)) & 0xff).join('.'));
+    }
+  }
+  return [...out];
+}
+
+/** Announces a game on the LAN every DISCOVERY_INTERVAL_MS (readme §3). */
+export class Announcer {
+  private readonly socket: Socket;
+  private timer: NodeJS.Timeout | null = null;
+
+  constructor(private readonly announcement: () => Announcement) {
+    this.socket = createSocket({ type: 'udp4', reuseAddr: true });
+    this.socket.on('error', (err) => console.error('[discovery] socket error:', err.message));
+  }
+
+  start(): void {
+    this.socket.bind(0, () => {
+      this.socket.setBroadcast(true);
+      this.send();
+      this.timer = setInterval(() => this.send(), DISCOVERY_INTERVAL_MS);
+    });
+  }
+
+  stop(): void {
+    if (this.timer) clearInterval(this.timer);
+    this.socket.close();
+  }
+
+  private send(): void {
+    const data = Buffer.from(JSON.stringify(this.announcement()));
+    for (const address of broadcastAddresses()) {
+      this.socket.send(data, DISCOVERY_PORT, address, (err) => {
+        // Some interfaces refuse broadcasts (VPNs, disconnected adapters): not fatal.
+        if (err && address !== '255.255.255.255') console.warn(`[discovery] cannot announce on ${address}: ${err.message}`);
+      });
+    }
+  }
+}

+ 3 - 2
src/server/game.ts

@@ -133,9 +133,10 @@ export class Game {
     return [...this.players.values()].map((p) => p.info);
   }
 
-  addPlayer(name: string, characterId: string): PlayerInfo {
+  /** Adds a player. The transport passes its own `id` so players keep it across matches. */
+  addPlayer(name: string, characterId: string, id = this.nextPlayerId++): PlayerInfo {
     const stats = this.loadCharacter(characterId);
-    const id = this.nextPlayerId++;
+    this.nextPlayerId = Math.max(this.nextPlayerId, id + 1);
     const body = this.world.createBody({ type: 'dynamic', fixedRotation: true, bullet: true });
     const info: PlayerInfo = { id, name, characterId, radius: stats.size };
     const player: ServerPlayer = {

+ 39 - 12
src/server/index.ts

@@ -1,6 +1,9 @@
 // Server entry point. Runs headless (`npm run server`) or inside an Electron utilityProcess.
-import { DEFAULT_MAP_ID, NET_PORT } from '../shared/constants';
+// Arguments: --map=<id> --port=<n> --seed=<n> --name=<game name>
+import { randomUUID } from 'node:crypto';
+import { DEFAULT_MAP_ID, MAX_PLAYERS, NET_PORT, PROTOCOL_VERSION } from '../shared/constants';
 import { loadCharacterStats, loadMap, loadMaterials, resolveAssetsDir } from './assets';
+import { Announcer } from './discovery';
 import { Game } from './game';
 import { GameServer } from './net';
 
@@ -20,26 +23,50 @@ function main(): void {
   const assetsDir = resolveAssetsDir();
   const mapId = arg('map') ?? DEFAULT_MAP_ID;
   const port = Number(arg('port') ?? NET_PORT);
+  const name = (arg('name') ?? 'Goûter').slice(0, 40);
   // The only place the seed is drawn; everything downstream uses the seeded RNG.
   const seed = Number(arg('seed') ?? Date.now() % 0x7fffffff);
 
   const materials = loadMaterials(assetsDir);
-  const { definition, terrain } = loadMap(assetsDir, mapId, materials);
-  const game = new Game({
-    map: definition,
-    terrain,
-    materials,
-    seed,
-    loadCharacter: (id) => loadCharacterStats(assetsDir, id),
-  });
+  // Validate the map once at startup: a broken map must fail here, not mid-game.
+  loadMap(assetsDir, mapId, materials);
+  const createGame = (gameSeed: number): Game => {
+    const { definition, terrain } = loadMap(assetsDir, mapId, materials);
+    return new Game({ map: definition, terrain, materials, seed: gameSeed, loadCharacter: (id) => loadCharacterStats(assetsDir, id) });
+  };
 
-  const server = new GameServer(game, port, '0.0.0.0', () => {
-    console.log(`[server] map "${mapId}" (seed ${seed}) listening on :${port}`);
-    parentPort?.postMessage({ type: 'ready', port });
+  const serverId = randomUUID();
+  let announcer: Announcer | null = null;
+  const server = new GameServer({
+    createGame,
+    seed,
+    port,
+    onListening: () => {
+      console.log(`[server] "${name}" on map "${mapId}" (seed ${seed}) listening on :${port}`);
+      announcer = new Announcer(() => ({
+        magic: 'goutermageddon',
+        serverId,
+        name,
+        mapId: server.mapId,
+        players: server.playerCount,
+        maxPlayers: MAX_PLAYERS,
+        phase: server.currentPhase,
+        version: PROTOCOL_VERSION,
+        port,
+      }));
+      announcer.start();
+      parentPort?.postMessage({ type: 'ready', port });
+    },
+    onError: (err) => {
+      console.error(`[server] cannot listen on :${port}:`, err.message);
+      parentPort?.postMessage({ type: 'error', message: err.message });
+      process.exit(1);
+    },
   });
   server.start();
 
   const shutdown = (): void => {
+    announcer?.stop();
     server.close();
     process.exit(0);
   };

+ 159 - 52
src/server/net.ts

@@ -1,35 +1,85 @@
 import { Packr } from 'msgpackr';
 import { WebSocketServer, type WebSocket } from 'ws';
 import { isCharacterId } from '../shared/characters';
-import { MAX_CATCHUP_TICKS, PROTOCOL_VERSION, TICK_DT, TICKS_PER_SNAPSHOT } from '../shared/constants';
-import { isClientMessage, type ServerMessage } from '../shared/protocol';
+import {
+  HASH_CHECK_INTERVAL_S,
+  MAX_CATCHUP_TICKS,
+  MAX_PLAYERS,
+  PROTOCOL_VERSION,
+  START_COUNTDOWN_S,
+  TICK_DT,
+  TICK_RATE,
+  TICKS_PER_SNAPSHOT,
+} from '../shared/constants';
+import { isClientMessage, type LobbyPlayer, type Phase, type ServerMessage } from '../shared/protocol';
+import { Rng } from '../shared/rng';
 import type { Game } from './game';
 
-const MAX_PLAYERS = 8;
 const packr = new Packr({ useRecords: false });
+const HASH_CHECK_TICKS = HASH_CHECK_INTERVAL_S * TICK_RATE;
 
 interface Client {
   socket: WebSocket;
-  playerId: number | null;
+  player: LobbyPlayer | null;
 }
 
-/** WebSocket transport + fixed-step loop around a Game. */
+export interface GameServerOptions {
+  /** Builds a fresh game on the map for a new match. */
+  createGame: (seed: number) => Game;
+  seed: number;
+  port: number;
+  host?: string;
+  onListening?: () => void;
+  /** Socket errors (e.g. port already in use). Without a handler they are thrown. */
+  onError?: (err: Error) => void;
+}
+
+/**
+ * WebSocket transport + fixed-step loop around a Game.
+ * Phases: in the lobby players warm up on the map and mark themselves ready; once all
+ * are ready a countdown starts, then a match begins on a freshly loaded map.
+ */
 export class GameServer {
   private readonly wss: WebSocketServer;
   private readonly clients = new Set<Client>();
+  private readonly rng: Rng;
+  private game: Game;
+  private phase: Phase = 'lobby';
+  /** Ticks left before the match starts, or null. */
+  private countdown: number | null = null;
+  private nextPlayerId = 1;
   private timer: NodeJS.Timeout | null = null;
 
-  constructor(
-    private readonly game: Game,
-    port: number,
-    host = '0.0.0.0',
-    onListening?: () => void,
-  ) {
-    this.wss = new WebSocketServer({ port, host });
-    this.wss.on('listening', () => onListening?.());
+  constructor(private readonly opts: GameServerOptions) {
+    this.rng = new Rng(opts.seed);
+    this.game = opts.createGame(this.rng.nextU32());
+    this.wss = new WebSocketServer({ port: opts.port, host: opts.host ?? '0.0.0.0' });
+    this.wss.on('listening', () => opts.onListening?.());
+    this.wss.on('error', (err) => {
+      if (!opts.onError) throw err;
+      opts.onError(err);
+    });
     this.wss.on('connection', (socket) => this.onConnection(socket));
   }
 
+  get currentPhase(): Phase {
+    return this.phase;
+  }
+
+  get mapId(): string {
+    return this.game.map.id;
+  }
+
+  get playerCount(): number {
+    return this.players().length;
+  }
+
+  /** The port actually bound (useful when created with port 0). */
+  get port(): number {
+    const addr = this.wss.address();
+    return typeof addr === 'object' && addr ? addr.port : this.opts.port;
+  }
+
   start(): void {
     let last = performance.now();
     let acc = 0;
@@ -54,7 +104,8 @@ export class GameServer {
     this.wss.close();
   }
 
-  private tickOnce(): void {
+  /** Runs one tick by hand (tests). */
+  tickOnce(): void {
     const result = this.game.step();
     for (const { seq, ev } of result.terrain) this.broadcast({ t: 'terrain', seq, ev });
     for (const id of result.removedChunks) this.broadcast({ t: 'chunkRemoved', id });
@@ -62,10 +113,22 @@ export class GameServer {
     if (this.game.tick % TICKS_PER_SNAPSHOT === 0) {
       this.broadcast({ t: 'snapshot', tick: this.game.tick, players: this.game.snapshot(), chunks: this.game.chunkStates() });
     }
+    if (this.game.tick % HASH_CHECK_TICKS === 0) {
+      this.broadcast({ t: 'hash', seq: this.game.events.length, hash: this.game.terrain.hash() });
+    }
+    if (this.countdown !== null) {
+      this.countdown--;
+      if (this.countdown <= 0) this.startMatch();
+      else if (this.countdown % TICK_RATE === 0) this.broadcastLobby(); // one update per second shown
+    }
+  }
+
+  private players(): LobbyPlayer[] {
+    return [...this.clients].flatMap((c) => (c.player ? [c.player] : []));
   }
 
   private onConnection(socket: WebSocket): void {
-    const client: Client = { socket, playerId: null };
+    const client: Client = { socket, player: null };
     this.clients.add(client);
     socket.on('message', (data: Buffer) => {
       let msg: unknown;
@@ -75,50 +138,94 @@ export class GameServer {
         return;
       }
       if (!isClientMessage(msg)) return;
-      if (msg.t === 'hello') {
-        if (msg.version !== PROTOCOL_VERSION) {
-          this.send(client, { t: 'reject', reason: 'version', serverVersion: PROTOCOL_VERSION });
-          socket.close();
-          return;
-        }
-        if (client.playerId !== null) return;
-        if (this.game.playerInfos().length >= MAX_PLAYERS) {
-          this.send(client, { t: 'reject', reason: 'full', serverVersion: PROTOCOL_VERSION });
-          socket.close();
-          return;
-        }
-        if (!isCharacterId(msg.characterId)) {
-          this.send(client, { t: 'reject', reason: 'character', serverVersion: PROTOCOL_VERSION });
-          socket.close();
-          return;
-        }
-        const name = msg.name.slice(0, 24) || 'Joueur';
-        const info = this.game.addPlayer(name, msg.characterId);
-        client.playerId = info.id;
-        this.send(client, {
-          t: 'welcome',
-          version: PROTOCOL_VERSION,
-          playerId: info.id,
-          mapId: this.game.map.id,
-          seed: this.game.seed,
-          events: [...this.game.events],
-          players: this.game.playerInfos(),
-          chunks: this.game.chunkInfos(),
-        });
-        this.broadcast({ t: 'playerJoined', player: info }, client);
-      } else if (client.playerId !== null) {
-        this.game.setInput(client.playerId, { buttons: msg.buttons, aimX: msg.aimX, aimY: msg.aimY });
+      if (msg.t === 'hello') this.onHello(client, msg.version, msg.name, msg.characterId);
+      else if (!client.player) return;
+      else if (msg.t === 'input') this.game.setInput(client.player.id, { buttons: msg.buttons, aimX: msg.aimX, aimY: msg.aimY });
+      else if (msg.t === 'ready') this.onReady(client.player, msg.ready);
+      else if (msg.t === 'desync') {
+        console.error(
+          `[net] DESYNC: player ${client.player.id} (${client.player.name}) has hash ${msg.hash} at seq ${msg.seq}; ` +
+            `server is at seq ${this.game.events.length}`,
+        );
       }
     });
     socket.on('close', () => {
       this.clients.delete(client);
-      if (client.playerId !== null) {
-        this.game.removePlayer(client.playerId);
-        this.broadcast({ t: 'playerLeft', id: client.playerId });
+      if (!client.player) return;
+      this.game.removePlayer(client.player.id);
+      this.broadcast({ t: 'playerLeft', id: client.player.id });
+      if (this.playerCount === 0) {
+        // Everyone left: back to the lobby on a fresh map for whoever comes next.
+        this.phase = 'lobby';
+        this.countdown = null;
+        this.game = this.opts.createGame(this.rng.nextU32());
       }
+      this.updateCountdown();
+      this.broadcastLobby();
     });
   }
 
+  private onHello(client: Client, version: number, rawName: string, characterId: string): void {
+    const reject = (reason: 'version' | 'full' | 'character'): void => {
+      this.send(client, { t: 'reject', reason, serverVersion: PROTOCOL_VERSION });
+      client.socket.close();
+    };
+    if (version !== PROTOCOL_VERSION) return reject('version');
+    if (client.player) return;
+    if (this.playerCount >= MAX_PLAYERS) return reject('full');
+    if (!isCharacterId(characterId)) return reject('character');
+
+    const player: LobbyPlayer = { id: this.nextPlayerId++, name: rawName.slice(0, 24) || 'Joueur', characterId, ready: false };
+    client.player = player;
+    const info = this.game.addPlayer(player.name, characterId, player.id);
+    this.send(client, {
+      t: 'welcome',
+      version: PROTOCOL_VERSION,
+      playerId: player.id,
+      mapId: this.game.map.id,
+      seed: this.game.seed,
+      events: [...this.game.events],
+      players: this.game.playerInfos(),
+      chunks: this.game.chunkInfos(),
+    });
+    this.broadcast({ t: 'playerJoined', player: info }, client);
+    // A newcomer is not ready: a running countdown stops.
+    this.updateCountdown();
+    this.broadcastLobby();
+  }
+
+  private onReady(player: LobbyPlayer, ready: boolean): void {
+    if (this.phase !== 'lobby' || player.ready === ready) return;
+    player.ready = ready;
+    this.updateCountdown();
+    this.broadcastLobby();
+  }
+
+  private updateCountdown(): void {
+    const players = this.players();
+    const allReady = this.phase === 'lobby' && players.length > 0 && players.every((p) => p.ready);
+    if (allReady && this.countdown === null) this.countdown = START_COUNTDOWN_S * TICK_RATE;
+    else if (!allReady) this.countdown = null;
+  }
+
+  /** Reloads the map and puts every connected player in it. */
+  private startMatch(): void {
+    this.countdown = null;
+    this.phase = 'playing';
+    this.game = this.opts.createGame(this.rng.nextU32());
+    for (const p of this.players()) {
+      p.ready = false;
+      this.game.addPlayer(p.name, p.characterId, p.id);
+    }
+    this.broadcast({ t: 'start', mapId: this.game.map.id, seed: this.game.seed, players: this.game.playerInfos() });
+    this.broadcastLobby();
+  }
+
+  private broadcastLobby(): void {
+    const countdown = this.countdown === null ? null : Math.ceil(this.countdown / TICK_RATE);
+    this.broadcast({ t: 'lobby', phase: this.phase, players: this.players(), countdown });
+  }
+
   private send(client: Client, msg: ServerMessage): void {
     if (client.socket.readyState === client.socket.OPEN) client.socket.send(packr.pack(msg));
   }
@@ -126,7 +233,7 @@ export class GameServer {
   private broadcast(msg: ServerMessage, except?: Client): void {
     const data = packr.pack(msg);
     for (const c of this.clients) {
-      if (c !== except && c.playerId !== null && c.socket.readyState === c.socket.OPEN) c.socket.send(data);
+      if (c !== except && c.player && c.socket.readyState === c.socket.OPEN) c.socket.send(data);
     }
   }
 }

+ 9 - 1
src/shared/constants.ts

@@ -2,10 +2,18 @@
 // (characters, maps, materials) whenever they belong to a specific entity.
 
 /** Bumped on every incompatible protocol change. */
-export const PROTOCOL_VERSION = 3;
+export const PROTOCOL_VERSION = 4;
 
 export const NET_PORT = 47100;
 export const DISCOVERY_PORT = 47101;
+export const MAX_PLAYERS = 8;
+/** Servers announce themselves this often; clients forget a server not heard for DISCOVERY_TTL_MS. */
+export const DISCOVERY_INTERVAL_MS = 1000;
+export const DISCOVERY_TTL_MS = 3500;
+/** Seconds between two bitmap hash comparisons (readme §9, M3). */
+export const HASH_CHECK_INTERVAL_S = 5;
+/** Countdown once every player in the lobby is ready (s). */
+export const START_COUNTDOWN_S = 3;
 
 export const TICK_RATE = 60;
 export const TICK_DT = 1 / TICK_RATE;

+ 53 - 2
src/shared/protocol.ts

@@ -45,10 +45,54 @@ export interface ChunkInfo {
   state: ChunkState;
 }
 
+export type Phase = 'lobby' | 'playing';
+
+export interface LobbyPlayer {
+  id: number;
+  name: string;
+  characterId: string;
+  ready: boolean;
+}
+
+/** UDP broadcast payload (JSON) announcing a game on the LAN. */
+export interface Announcement {
+  magic: typeof ANNOUNCE_MAGIC;
+  /** Random per server process, to merge announcements received on several interfaces. */
+  serverId: string;
+  name: string;
+  mapId: string;
+  players: number;
+  maxPlayers: number;
+  phase: Phase;
+  version: number;
+  port: number;
+}
+
+export const ANNOUNCE_MAGIC = 'goutermageddon';
+
+export function isAnnouncement(msg: unknown): msg is Announcement {
+  if (!msg || typeof msg !== 'object') return false;
+  const m = msg as Record<string, unknown>;
+  return (
+    m.magic === ANNOUNCE_MAGIC &&
+    typeof m.serverId === 'string' &&
+    typeof m.name === 'string' &&
+    typeof m.mapId === 'string' &&
+    typeof m.players === 'number' &&
+    typeof m.maxPlayers === 'number' &&
+    typeof m.version === 'number' &&
+    typeof m.port === 'number' &&
+    (m.phase === 'lobby' || m.phase === 'playing')
+  );
+}
+
 // Client -> server
 export type ClientMessage =
   | { t: 'hello'; version: number; name: string; characterId: string }
-  | ({ t: 'input' } & InputState);
+  | ({ t: 'input' } & InputState)
+  | { t: 'ready'; ready: boolean }
+  /** The client's bitmap hash differs from the server's at `seq`. */
+  | { t: 'desync'; seq: number; hash: number };
 
 // Server -> client
 export type ServerMessage =
@@ -70,7 +114,12 @@ export type ServerMessage =
   /** A chunk left the game without merging (fell off the map). */
   | { t: 'chunkRemoved'; id: number }
   | { t: 'crushed'; id: number; x: number; y: number }
-  | { t: 'snapshot'; tick: number; players: PlayerState[]; chunks: ChunkState[] };
+  | { t: 'snapshot'; tick: number; players: PlayerState[]; chunks: ChunkState[] }
+  | { t: 'lobby'; phase: Phase; players: LobbyPlayer[]; countdown: number | null }
+  /** A new match starts on a freshly loaded map: clients drop their world and reload. */
+  | { t: 'start'; mapId: string; seed: number; players: PlayerInfo[] }
+  /** Consistency check: the server's bitmap hash once `seq` terrain events have been applied. */
+  | { t: 'hash'; seq: number; hash: number };
 
 export function hello(name: string, characterId: string): ClientMessage {
   return { t: 'hello', version: PROTOCOL_VERSION, name, characterId };
@@ -81,5 +130,7 @@ export function isClientMessage(msg: unknown): msg is ClientMessage {
   const m = msg as Record<string, unknown>;
   if (m.t === 'hello') return typeof m.version === 'number' && typeof m.name === 'string' && typeof m.characterId === 'string';
   if (m.t === 'input') return typeof m.buttons === 'number' && typeof m.aimX === 'number' && typeof m.aimY === 'number';
+  if (m.t === 'ready') return typeof m.ready === 'boolean';
+  if (m.t === 'desync') return typeof m.seq === 'number' && typeof m.hash === 'number';
   return false;
 }

+ 45 - 0
tests/discovery.test.ts

@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest';
+import { DiscoveryListener } from '../src/main/discovery';
+import { Announcer, broadcastAddresses } from '../src/server/discovery';
+import { MAX_PLAYERS, PROTOCOL_VERSION } from '../src/shared/constants';
+import { isAnnouncement, type Announcement } from '../src/shared/protocol';
+
+const announcement: Announcement = {
+  magic: 'goutermageddon',
+  serverId: 'test-server',
+  name: 'Goûter de test',
+  mapId: 'gateau-etages',
+  players: 2,
+  maxPlayers: MAX_PLAYERS,
+  phase: 'lobby',
+  version: PROTOCOL_VERSION,
+  port: 47100,
+};
+
+describe('LAN discovery', () => {
+  it('validates announcements', () => {
+    expect(isAnnouncement(announcement)).toBe(true);
+    expect(isAnnouncement({ ...announcement, magic: 'other-game' })).toBe(false);
+    expect(isAnnouncement({ ...announcement, phase: 'nope' })).toBe(false);
+  });
+
+  it('always announces on loopback and the limited broadcast', () => {
+    expect(broadcastAddresses()).toEqual(expect.arrayContaining(['127.0.0.1', '255.255.255.255']));
+  });
+
+  it('a listener hears an announcer on the same machine, once, with its address', async () => {
+    const listener = new DiscoveryListener();
+    listener.start((err) => {
+      throw err;
+    });
+    const announcer = new Announcer(() => announcement);
+    announcer.start();
+    await new Promise((r) => setTimeout(r, 400));
+    const games = listener.list();
+    announcer.stop();
+    listener.stop();
+    expect(games).toHaveLength(1);
+    expect(games[0]).toMatchObject({ serverId: 'test-server', name: 'Goûter de test', players: 2, port: 47100 });
+    expect(games[0].address).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
+  });
+});

+ 178 - 0
tests/net.test.ts

@@ -0,0 +1,178 @@
+import { Packr } from 'msgpackr';
+import { afterEach, describe, expect, it } from 'vitest';
+import WebSocket from 'ws';
+import { loadCharacterStats, loadMap } from '../src/server/assets';
+import { Game } from '../src/server/game';
+import { GameServer } from '../src/server/net';
+import { HASH_CHECK_INTERVAL_S, PROTOCOL_VERSION, START_COUNTDOWN_S, TICK_RATE } from '../src/shared/constants';
+import { Button } from '../src/shared/input';
+import type { ClientMessage, ServerMessage } from '../src/shared/protocol';
+import type { TerrainBitmap } from '../src/shared/terrain/bitmap';
+import { applyTerrainEvent } from '../src/shared/terrain/events';
+import { ASSETS, realMaterials } from './helpers';
+
+const packr = new Packr({ useRecords: false });
+const materials = realMaterials();
+const freshTerrain = (): TerrainBitmap => loadMap(ASSETS, 'gateau-etages', materials).terrain;
+const flush = (ms = 30): Promise<void> => new Promise((r) => setTimeout(r, ms));
+
+/** A headless client that keeps its own bitmap in sync, like the renderer does. */
+class TestClient {
+  readonly messages: ServerMessage[] = [];
+  readonly socket: WebSocket;
+  terrain = freshTerrain();
+  lastSeq = 0;
+  playerId = -1;
+  hashChecks: { seq: number; server: number; mine: number }[] = [];
+
+  constructor(port: number) {
+    this.socket = new WebSocket(`ws://127.0.0.1:${port}`);
+    this.socket.on('message', (data: Buffer) => this.onMessage(packr.unpack(data) as ServerMessage));
+  }
+
+  async hello(name: string, characterId = 'generic', version = PROTOCOL_VERSION): Promise<void> {
+    await new Promise((r) => this.socket.once('open', r));
+    this.send({ t: 'hello', version, name, characterId });
+  }
+
+  send(msg: ClientMessage): void {
+    this.socket.send(packr.pack(msg));
+  }
+
+  last<T extends ServerMessage['t']>(t: T): Extract<ServerMessage, { t: T }> | undefined {
+    return [...this.messages].reverse().find((m) => m.t === t) as Extract<ServerMessage, { t: T }> | undefined;
+  }
+
+  private onMessage(msg: ServerMessage): void {
+    this.messages.push(msg);
+    if (msg.t === 'welcome') {
+      this.playerId = msg.playerId;
+      for (const { seq, ev } of msg.events) this.apply(seq, ev);
+    } else if (msg.t === 'start') {
+      this.terrain = freshTerrain();
+      this.lastSeq = 0;
+    } else if (msg.t === 'terrain') {
+      this.apply(msg.seq, msg.ev);
+    } else if (msg.t === 'hash') {
+      expect(this.lastSeq).toBe(msg.seq);
+      this.hashChecks.push({ seq: msg.seq, server: msg.hash, mine: this.terrain.hash() });
+    }
+  }
+
+  private apply(seq: number, ev: Parameters<typeof applyTerrainEvent>[1]): void {
+    expect(seq).toBe(this.lastSeq + 1);
+    this.lastSeq = seq;
+    applyTerrainEvent(this.terrain, ev, materials);
+  }
+}
+
+describe('LAN server (M3)', () => {
+  let server: GameServer | null = null;
+  const clients: TestClient[] = [];
+
+  afterEach(() => {
+    for (const c of clients) c.socket.close();
+    clients.length = 0;
+    server?.close();
+    server = null;
+  });
+
+  async function startServer(): Promise<GameServer> {
+    await new Promise<void>((resolve) => {
+      server = new GameServer({
+        createGame: (seed) => {
+          const { definition, terrain } = loadMap(ASSETS, 'gateau-etages', materials);
+          return new Game({ map: definition, terrain, materials, seed, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
+        },
+        seed: 42,
+        port: 0,
+        host: '127.0.0.1',
+        onListening: resolve,
+      });
+    });
+    return server!;
+  }
+
+  function tick(n: number): void {
+    for (let i = 0; i < n; i++) server!.tickOnce();
+  }
+
+  it('rejects a client with another protocol version', async () => {
+    const s = await startServer();
+    const c = new TestClient(s.port);
+    clients.push(c);
+    await c.hello('Vieux', 'generic', PROTOCOL_VERSION - 1);
+    await flush();
+    expect(c.last('reject')).toMatchObject({ reason: 'version', serverVersion: PROTOCOL_VERSION });
+  });
+
+  it('lobby → everyone ready → countdown → match on a fresh map, clients stay bit-identical', async () => {
+    const s = await startServer();
+    const a = new TestClient(s.port);
+    const b = new TestClient(s.port);
+    clients.push(a, b);
+    await a.hello('Alice');
+    await b.hello('Bob', 'castor');
+    await flush();
+    expect(a.last('lobby')).toMatchObject({ phase: 'lobby', countdown: null });
+    expect(a.last('lobby')!.players.map((p) => p.name)).toEqual(['Alice', 'Bob']);
+
+    // Warm-up destruction, then only one player ready: no countdown.
+    tick(120);
+    a.send({ t: 'ready', ready: true });
+    await flush();
+    tick(1);
+    await flush();
+    expect(b.last('lobby')).toMatchObject({ countdown: null });
+
+    b.send({ t: 'ready', ready: true });
+    await flush();
+    expect(a.last('lobby')!.countdown).toBe(START_COUNTDOWN_S);
+    tick(START_COUNTDOWN_S * TICK_RATE);
+    await flush();
+    expect(a.last('start')).toBeDefined();
+    expect(b.last('start')!.players.map((p) => p.id).sort()).toEqual([a.playerId, b.playerId].sort());
+    expect(a.last('lobby')).toMatchObject({ phase: 'playing' });
+
+    // Both dig for a while; snapshots, terrain events and hash checks flow to both clients.
+    for (let i = 0; i < HASH_CHECK_INTERVAL_S * TICK_RATE * 2; i++) {
+      if (i % 10 === 0) {
+        const snap = a.last('snapshot');
+        for (const [c, dy] of [
+          [a, 40],
+          [b, 60],
+        ] as const) {
+          const me = snap?.players.find((p) => p.id === c.playerId);
+          if (me) c.send({ t: 'input', buttons: c === a ? Button.Attack : Button.Special, aimX: Math.round(me.x), aimY: Math.round(me.y + dy) });
+        }
+        await flush(5);
+      }
+      tick(1);
+    }
+    await flush(100);
+    expect(a.lastSeq).toBeGreaterThan(5);
+    for (const c of [a, b]) {
+      expect(c.hashChecks.length).toBeGreaterThanOrEqual(2);
+      for (const h of c.hashChecks) expect(h.mine).toBe(h.server);
+    }
+    expect(a.terrain.hash()).toBe(b.terrain.hash());
+  });
+
+  it('a player leaving is announced and an empty server goes back to the lobby', async () => {
+    const s = await startServer();
+    const a = new TestClient(s.port);
+    const b = new TestClient(s.port);
+    clients.push(a, b);
+    await a.hello('Alice');
+    await b.hello('Bob');
+    await flush();
+    b.socket.close();
+    await flush();
+    expect(a.last('playerLeft')).toMatchObject({ id: b.playerId });
+    expect(a.last('lobby')!.players).toHaveLength(1);
+    a.socket.close();
+    await flush();
+    expect(s.currentPhase).toBe('lobby');
+    expect(s.playerCount).toBe(0);
+  });
+});