Ver Fonte

feat(maps): map catalog, lobby rotation, catch-up pick, modifiers, two new maps (M6)

- The server validates every folder in assets/maps at startup (map.json,
  PNG, known modifiers) and rejects broken maps with an explicit message;
  only valid maps are offered.
- Rotation set by the host (first player still connected) in the warm-up
  panel: all maps in order, at random, or a selection. The warm-up runs on
  the rotation's first map.
- Catch-up rule: during the round results the last-placed player picks the
  next map among the rotation; without a pick the rotation decides.
- Map modifiers in server/modifiers, active during rounds only: wind (side
  gusts on players, items and the cherry, shown in the round banner) and
  random-cracks (zigzag cracks in brittle biscuit).
- New maps generated by scripts/gen-maps.ts (renamed from gen-test-map,
  npm run gen:maps): Le Mille-Feuille (pastry sheets and cream, a missing
  slice; random-cracks) and La Pièce Montée (stepped pyramid of choux, each
  glued only to the one below, so removing one drops the column; wind).
  Le Gâteau à Étages is unchanged pixel for pixel. Per-map background
  themes.
- Protocol v7. Tests: every built-in map validates, the choux column falls,
  rotation pool/default/last-placed, host-only rotation, the full pick flow
  over WebSocket, wind and cracks (deterministic), no modifiers in warm-up.

No new dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Idleman há 1 semana atrás
pai
commit
aa695a2afb

BIN
assets/maps/mille-feuille/background.png


+ 14 - 0
assets/maps/mille-feuille/map.json

@@ -0,0 +1,14 @@
+{
+  "id": "mille-feuille",
+  "name": "Le Mille-Feuille",
+  "size": [2048, 1024],
+  "materialPalette": {
+    "#000000": 0, "#C8964B": 5, "#FFFFFF": 3, "#5A3A22": 2, "#8899AA": 255
+  },
+  "spawns": [[400, 300], [1650, 300], [800, 300], [1250, 300]],
+  "cherrySpawn": [1024, 200],
+  "custard": { "startY": 1000, "speed": 2.5, "finalRushSpeed": 8 },
+  "items": { "interval": [6, 12], "weights": { "candle": 3, "smarties": 2, "whisk": 1 } },
+  "music": "birthday-flute",
+  "modifiers": ["random-cracks"]
+}

BIN
assets/maps/mille-feuille/terrain.png


BIN
assets/maps/mille-feuille/thumb.png


BIN
assets/maps/piece-montee/background.png


+ 14 - 0
assets/maps/piece-montee/map.json

@@ -0,0 +1,14 @@
+{
+  "id": "piece-montee",
+  "name": "La Pièce Montée",
+  "size": [2048, 1024],
+  "materialPalette": {
+    "#000000": 0, "#F2C46D": 1, "#FFFFFF": 3, "#D98A1E": 6, "#8899AA": 255
+  },
+  "spawns": [[300, 600], [1750, 600], [1024, 250], [700, 300]],
+  "cherrySpawn": [1024, 150],
+  "custard": { "startY": 1000, "speed": 2.2, "finalRushSpeed": 7 },
+  "items": { "interval": [6, 12], "weights": { "candle": 4, "smarties": 2, "whisk": 1 } },
+  "music": "birthday-flute",
+  "modifiers": ["wind"]
+}

BIN
assets/maps/piece-montee/terrain.png


BIN
assets/maps/piece-montee/thumb.png


+ 1 - 1
package.json

@@ -12,7 +12,7 @@
     "lint": "eslint . && tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
     "build": "electron-vite build",
     "dist": "electron-vite build && electron-builder --win",
-    "gen:test-map": "tsx scripts/gen-test-map.ts"
+    "gen:maps": "tsx scripts/gen-maps.ts"
   },
   "dependencies": {
     "msgpackr": "^1.12.1",

+ 7 - 1
readme.md

@@ -42,7 +42,7 @@ npm run server         # serveur headless en Node pur (tests, bots, debug)
                        #   ex. : npm run server -- --name="Test" --round=30 --rounds=2 --pause=5
 npm test               # vitest
 npm run bench          # benchmarks vitest (budget terrain §5)
-npm run gen:test-map   # régénère les PNG placeholder de gateau-etages
+npm run gen:maps       # régénère les PNG placeholder des cartes intégrées
 npm run lint           # eslint + tsc --noEmit
 npm run build          # build de prod
 npm run dist           # installeur Windows (electron-builder)
@@ -313,6 +313,12 @@ Chaque étape doit être **jouable** avant de passer à la suivante.
  
 - Loader validé, rotation configurable dans le lobby, choix par le dernier, `modifiers`.
 - Cartes `mille-feuille` et `piece-montee` en plus de `gateau-etages`.
+- **État :** implémenté.
+  - **Validation :** au démarrage, le serveur valide chaque dossier de `assets/maps/`, modificateurs inconnus compris. Une carte invalide est écartée avec un message explicite dans le journal.
+  - **Rotation :** l'hôte (👑, le premier arrivé) la règle dans le panneau d'échauffement : toutes dans l'ordre, au hasard, ou une sélection. L'échauffement se fait sur la première carte de la rotation.
+  - **Choix par le dernier :** pendant l'écran de fin de manche, le dernier choisit la carte suivante parmi la rotation (logique dans `server/rotation.ts`). Sans choix, la rotation décide.
+  - **Modificateurs** (`server/modifiers/`) : `wind` (rafales latérales, indicateur dans le bandeau) et `random-cracks` (fissures en zigzag dans le biscuit). Ils ne jouent que pendant les manches. Pour les tester dès maintenant, `random-cracks` est sur le Mille-Feuille et `wind` sur la Pièce Montée ; à redistribuer sur la Tarte et les Crêpes en M7.
+  - **Cartes :** générées par `npm run gen:maps`. Sur la Pièce Montée, chaque chou n'est collé qu'à celui du dessous, donc en retirer un fait tomber toute la colonne au-dessus.
 ### M7 et suite
  
 - Les 5 autres personnages, les 3 autres cartes, le rattrapage complet, les trophées, la DA finale, l'audio, l'éditeur de cartes, l'installeur Windows.

+ 158 - 61
scripts/gen-test-map.ts → scripts/gen-maps.ts

@@ -1,12 +1,12 @@
-// Generates the placeholder PNGs of the reference map "gateau-etages".
-// Usage: npm run gen:test-map
+// Generates the placeholder PNGs (terrain, background, thumbnail) of every built-in map.
+// Usage: npm run gen:maps
 import { mkdirSync, writeFileSync } from 'node:fs';
 import { resolve } from 'node:path';
 import { PNG } from 'pngjs';
 
 const W = 2048;
 const H = 1024;
-const outDir = resolve(import.meta.dirname, '../assets/maps/gateau-etages');
+const mapsDir = resolve(import.meta.dirname, '../assets/maps');
 
 const VOID = 0x000000;
 const SPONGE = 0xf2c46d;
@@ -14,60 +14,135 @@ const CHOCOLATE = 0x5a3a22;
 const CREAM = 0xffffff;
 const JELLY = 0xd93a5b;
 const BISCUIT = 0xc8964b;
+const CARAMEL = 0xd98a1e;
 const PLATE = 0x8899aa;
 
-const terrain = new Uint32Array(W * H).fill(VOID);
-const put = (x: number, y: number, c: number): void => {
-  if (x >= 0 && y >= 0 && x < W && y < H) terrain[y * W + x] = c;
-};
-const rect = (x0: number, y0: number, x1: number, y1: number, c: number): void => {
-  for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) put(x, y, c);
-};
+/** A terrain image being drawn, one 0xRRGGBB color per pixel (colors map to materials in map.json). */
+class Canvas {
+  readonly px = new Uint32Array(W * H).fill(VOID);
+
+  put(x: number, y: number, c: number): void {
+    if (x >= 0 && y >= 0 && x < W && y < H) this.px[y * W + x] = c;
+  }
 
-// Plate: a wide dish with a raised rim.
-rect(180, 960, 1868, 990, PLATE);
-rect(150, 940, 200, 975, PLATE);
-rect(1848, 940, 1898, 975, PLATE);
+  rect(x0: number, y0: number, x1: number, y1: number, c: number): void {
+    for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) this.put(x, y, c);
+  }
+
+  disc(cx: number, cy: number, r: number, c: number): void {
+    for (let y = Math.floor(cy - r); y <= cy + r; y++) {
+      for (let x = Math.floor(cx - r); x <= cx + r; x++) if ((x - cx) ** 2 + (y - cy) ** 2 <= r * r) this.put(x, y, c);
+    }
+  }
 
-interface Tier {
-  x0: number;
-  x1: number;
-  top: number;
-  bottom: number;
+  /** The dish everything stands on (the anchor). */
+  plate(): void {
+    this.rect(180, 960, 1868, 990, PLATE);
+    this.rect(150, 940, 200, 975, PLATE);
+    this.rect(1848, 940, 1898, 975, PLATE);
+  }
+
+  /** Whipped cream along the top of [x0, x1) at `top`, with drips. */
+  frosting(x0: number, x1: number, top: number): void {
+    for (let x = x0 - 6; x < x1 + 6; x++) {
+      const wave = Math.round(6 + 4 * Math.sin(x / 23));
+      for (let y = top - wave; y < top + 12; y++) this.put(x, y, CREAM);
+      const drip = (x * 7919) % 97 < 6 ? 18 + ((x * 31) % 22) : 0;
+      if (x >= x0 && x < x1) for (let y = top + 12; y < top + 12 + drip; y++) this.put(x, y, CREAM);
+    }
+    for (let y = top; y < top + 60; y++) {
+      const len = Math.max(0, 6 - Math.floor((y - top) / 10));
+      for (let i = 0; i < len; i++) {
+        this.put(x0 - 6 + i, y, CREAM);
+        this.put(x1 + 5 - i, y, CREAM);
+      }
+    }
+  }
 }
-const tiers: Tier[] = [
-  { x0: 280, x1: 1768, top: 700, bottom: 960 },
-  { x0: 520, x1: 1528, top: 470, bottom: 700 },
-  { x0: 780, x1: 1268, top: 270, bottom: 470 },
-];
-
-for (const t of tiers) {
-  const h = t.bottom - t.top;
-  rect(t.x0, t.top, t.x1, t.bottom, SPONGE);
-  // Biscuit base, jelly layer and chocolate layer.
-  rect(t.x0, t.bottom - Math.round(h * 0.12), t.x1, t.bottom, BISCUIT);
-  rect(t.x0, t.top + Math.round(h * 0.55), t.x1, t.top + Math.round(h * 0.62), JELLY);
-  rect(t.x0, t.top + Math.round(h * 0.3), t.x1, t.top + Math.round(h * 0.36), CHOCOLATE);
-  // Chocolate pillar in the middle, a hard core to dig around.
-  const mid = Math.round((t.x0 + t.x1) / 2);
-  rect(mid - 20, t.top + 20, mid + 20, t.bottom - Math.round(h * 0.12), CHOCOLATE);
-  // Whipped cream frosting with drips.
-  for (let x = t.x0 - 6; x < t.x1 + 6; x++) {
-    const wave = Math.round(6 + 4 * Math.sin(x / 23));
-    for (let y = t.top - wave; y < t.top + 12; y++) put(x, y, CREAM);
-    const drip = (x * 7919) % 97 < 6 ? 18 + ((x * 31) % 22) : 0;
-    if (x >= t.x0 && x < t.x1) for (let y = t.top + 12; y < t.top + 12 + drip; y++) put(x, y, CREAM);
+
+/** Le Gâteau à Étages: three tiers of sponge with chocolate and jelly layers. */
+function gateauEtages(): Canvas {
+  const c = new Canvas();
+  c.plate();
+  const tiers = [
+    { x0: 280, x1: 1768, top: 700, bottom: 960 },
+    { x0: 520, x1: 1528, top: 470, bottom: 700 },
+    { x0: 780, x1: 1268, top: 270, bottom: 470 },
+  ];
+  for (const t of tiers) {
+    const h = t.bottom - t.top;
+    c.rect(t.x0, t.top, t.x1, t.bottom, SPONGE);
+    // Biscuit base, jelly layer and chocolate layer.
+    c.rect(t.x0, t.bottom - Math.round(h * 0.12), t.x1, t.bottom, BISCUIT);
+    c.rect(t.x0, t.top + Math.round(h * 0.55), t.x1, t.top + Math.round(h * 0.62), JELLY);
+    c.rect(t.x0, t.top + Math.round(h * 0.3), t.x1, t.top + Math.round(h * 0.36), CHOCOLATE);
+    // Chocolate pillar in the middle, a hard core to dig around.
+    const mid = Math.round((t.x0 + t.x1) / 2);
+    c.rect(mid - 20, t.top + 20, mid + 20, t.bottom - Math.round(h * 0.12), CHOCOLATE);
+    c.frosting(t.x0, t.x1, t.top);
   }
-  for (let y = t.top; y < t.top + 60; y++) {
-    const len = Math.max(0, 6 - Math.floor((y - t.top) / 10));
-    for (let i = 0; i < len; i++) {
-      put(t.x0 - 6 + i, y, CREAM);
-      put(t.x1 + 5 - i, y, CREAM);
+  return c;
+}
+
+/**
+ * Le Mille-Feuille: thin brittle pastry sheets alternating with thick soft cream, so digging
+ * down is quick while every pastry sheet makes tunnelling sideways a chore. A slice is missing
+ * in the middle: two towers on a common base.
+ */
+function milleFeuille(): Canvas {
+  const c = new Canvas();
+  c.plate();
+  const x0 = 260;
+  const x1 = 1788;
+  const top = 440;
+  const bottom = 960;
+  const PASTRY = 16;
+  const FILLING = 44;
+  for (let y = top; y < bottom; y++) {
+    const layer = (y - top) % (PASTRY + FILLING);
+    c.rect(x0, y, x1, y + 1, layer < PASTRY ? BISCUIT : CREAM);
+  }
+  // Fondant icing with the classic chocolate feathering.
+  c.rect(x0 - 4, top - 18, x1 + 4, top, CREAM);
+  for (let x = x0; x < x1; x++) {
+    for (const row of [top - 14, top - 8]) if (Math.abs(((x + row * 3) % 60) - 30) < 2) c.rect(x, row, x + 1, row + 3, CHOCOLATE);
+    if (x % 60 < 3) c.rect(x, top - 16, x + 1, top - 2, CHOCOLATE);
+  }
+  // The missing slice: a trench down to the third pastry sheet.
+  c.rect(954, top - 18, 1094, top + 2 * (PASTRY + FILLING) + PASTRY, VOID);
+  return c;
+}
+
+/**
+ * La Pièce Montée: a stepped pyramid of choux on a nougatine base. Each chou is glued with
+ * caramel to the one under it only, so taking one out drops the whole column above it.
+ */
+function pieceMontee(): Canvas {
+  const c = new Canvas();
+  c.plate();
+  // Nougatine base.
+  c.rect(470, 905, 1578, 960, CARAMEL);
+  const R = 40;
+  const STEP_X = 82; // 2 px gap between neighbours: they never touch sideways
+  const STEP_Y = 82;
+  const base = 13;
+  const cx0 = 1024;
+  for (let row = 0; 2 * row < base; row++) {
+    const count = base - 2 * row;
+    const cy = 905 - R - 2 - row * STEP_Y;
+    for (let i = 0; i < count; i++) {
+      const cx = cx0 + (i - (count - 1) / 2) * STEP_X;
+      c.disc(cx, cy, R, SPONGE);
+      c.disc(cx, cy, R - 14, CREAM);
+      // Caramel glaze on top and the glue joint with what is below.
+      for (let a = -2.4; a <= -0.7; a += 0.02) c.disc(cx + Math.cos(a) * (R - 4), cy + Math.sin(a) * (R - 4), 5, CARAMEL);
+      c.disc(cx, cy + R + 1, 11, CARAMEL);
     }
   }
+  return c;
 }
 
-function writePng(name: string, w: number, h: number, pixel: (x: number, y: number) => number): void {
+function writePng(dir: string, name: string, w: number, h: number, pixel: (x: number, y: number) => number): void {
   const png = new PNG({ width: w, height: h });
   for (let y = 0; y < h; y++) {
     for (let x = 0; x < w; x++) {
@@ -79,7 +154,7 @@ function writePng(name: string, w: number, h: number, pixel: (x: number, y: numb
       png.data[i + 3] = 0xff;
     }
   }
-  writeFileSync(resolve(outDir, name), PNG.sync.write(png));
+  writeFileSync(resolve(dir, name), PNG.sync.write(png));
 }
 
 const lerp = (a: number, b: number, t: number): number => Math.round(a + (b - a) * t);
@@ -97,7 +172,19 @@ function hash(x: number, y: number): number {
   return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
 }
 
-function paintBackground(): Uint32Array {
+interface Theme {
+  stripe: number;
+  cloth: [number, number, number];
+  flags: number[];
+}
+
+const THEMES: Record<string, Theme> = {
+  'gateau-etages': { stripe: 0xffdbe3, cloth: [0xe85d75, 0xf7a8b8, 0xfff3f5], flags: [0xff6f91, 0xffe14d, 0x4dc4ff, 0x9be15d, 0xb18cff] },
+  'mille-feuille': { stripe: 0xd9ecff, cloth: [0x4d8fd9, 0xa8cdf7, 0xf3f8ff], flags: [0x4dc4ff, 0xffffff, 0xffe14d, 0x9be15d, 0x4dc4ff] },
+  'piece-montee': { stripe: 0xfff0c2, cloth: [0xd9a01e, 0xf7d98a, 0xfffaf0], flags: [0xffe14d, 0xff9a2e, 0xffffff, 0xff6f91, 0xffd27a] },
+};
+
+function paintBackground(theme: Theme): Uint32Array {
   const img = new Uint32Array(W * H);
   type Inside = (x: number, y: number) => boolean;
   /** Fills a shape (inside test over a bounding box) with a color function and a felt outline. */
@@ -119,7 +206,7 @@ function paintBackground(): Uint32Array {
   shape(0, 0, W, TABLE_Y, () => true, (x, y) => {
     const stripe = x % 96 < 30;
     const dot = !stripe && ((x + (Math.floor(y / 40) % 2) * 24) % 48) ** 2 / 9 + ((y % 40) - 20) ** 2 / 9 < 4;
-    const base = stripe ? 0xffdbe3 : dot ? 0xf7c9a8 : 0xfff3dc;
+    const base = stripe ? theme.stripe : dot ? 0xf7c9a8 : 0xfff3dc;
     return mix(base, 0xf6d7b0, Math.max(0, (y - 300) / 900));
   }, 0);
   // Skirting board.
@@ -182,7 +269,7 @@ function paintBackground(): Uint32Array {
   // Bunting garland across the top.
   const sag = (x: number): number => 30 + 70 * Math.sin((x / W) * Math.PI);
   shape(0, 0, W, 130, (x, y) => Math.abs(y - sag(x)) < 3, solid(INK), 0);
-  const flagColors = [0xff6f91, 0xffe14d, 0x4dc4ff, 0x9be15d, 0xb18cff];
+  const flagColors = theme.flags;
   for (let i = 0, fx = 20; fx < W; i++, fx += 110) {
     const top = sag(fx + 35);
     const inside: Inside = (x, y) => {
@@ -200,21 +287,31 @@ function paintBackground(): Uint32Array {
     const cy = Math.floor((y - TABLE_Y) / (cell * 0.6));
     const a = (cx & 1) === 0;
     const b = (cy & 1) === 0;
-    const base = a && b ? 0xe85d75 : a || b ? 0xf7a8b8 : 0xfff3f5;
+    const base = a && b ? theme.cloth[0] : a || b ? theme.cloth[1] : theme.cloth[2];
     return mix(base, 0x7a3a2a, depth * 0.25);
   });
   shape(0, TABLE_Y - 6, W, TABLE_Y + 30, (x, y) => y >= TABLE_Y - 6 && y < TABLE_Y + 14 + 12 * Math.abs(Math.sin((x / 40) * Math.PI)), (x, y) => (((x % 40) - 20) ** 2 + (y - TABLE_Y - 8) ** 2 < 25 ? 0xf3dfe3 : 0xffffff), 3);
   return img;
 }
 
-mkdirSync(outDir, { recursive: true });
-writePng('terrain.png', W, H, (x, y) => terrain[y * W + x]);
-const background = paintBackground();
-writePng('background.png', W, H, (x, y) => background[y * W + x]);
+const MAPS: Record<string, () => Canvas> = {
+  'gateau-etages': gateauEtages,
+  'mille-feuille': milleFeuille,
+  'piece-montee': pieceMontee,
+};
+
 const TW = 256;
 const TH = 128;
-writePng('thumb.png', TW, TH, (x, y) => {
-  const c = terrain[Math.floor((y * H) / TH) * W + Math.floor((x * W) / TW)];
-  return c === VOID ? 0xbfe6ff : c;
-});
-console.log(`Wrote terrain.png, background.png and thumb.png to ${outDir}`);
+for (const [id, build] of Object.entries(MAPS)) {
+  const dir = resolve(mapsDir, id);
+  mkdirSync(dir, { recursive: true });
+  const terrain = build().px;
+  writePng(dir, 'terrain.png', W, H, (x, y) => terrain[y * W + x]);
+  const background = paintBackground(THEMES[id]);
+  writePng(dir, 'background.png', W, H, (x, y) => background[y * W + x]);
+  writePng(dir, 'thumb.png', TW, TH, (x, y) => {
+    const c = terrain[Math.floor((y * H) / TH) * W + Math.floor((x * W) / TW)];
+    return c === VOID ? 0xbfe6ff : c;
+  });
+  console.log(`Wrote ${id}: terrain.png, background.png, thumb.png`);
+}

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

@@ -43,6 +43,16 @@ export const fr = {
     waiting: (n: number) => `En attente de ${n} gourmand${n > 1 ? 's' : ''}`,
     started: 'C’est parti !',
   },
+  rotation: {
+    title: 'Cartes de la partie',
+    modes: { all: 'Toutes, dans l’ordre', random: 'Toutes, au hasard', selection: 'Ma sélection' } as Record<string, string>,
+    hostOnly: 'C’est l’hôte (👑) qui choisit.',
+  },
+  pick: {
+    you: 'Tu es dernier : à toi de choisir la prochaine carte !',
+    other: (name: string) => `${name} est dernier et choisit la prochaine carte…`,
+    picked: (map: string) => `Prochaine carte : ${map}`,
+  },
   desync: 'Oups, ton gâteau ne ressemble plus à celui du serveur (désynchronisation).',
   hud: {
     round: (n: number, total: number) => `Manche ${n}/${total}`,

+ 9 - 3
src/renderer/main.ts

@@ -150,7 +150,7 @@ async function start(): Promise<void> {
     popups.container,
   );
   const hud = new RoundHud();
-  const results = new ResultsScreen();
+  const results = new ResultsScreen((mapId) => conn.send({ t: 'pickMap', mapId }));
   let round = 0;
   let rounds = 0;
 
@@ -332,7 +332,9 @@ async function start(): Promise<void> {
   const lobbyPanel = new LobbyPanel(
     () => localId,
     (ready) => conn.send({ t: 'ready', ready }),
+    (rotation) => conn.send({ t: 'rotation', rotation }),
   );
+  let mapNames = new Map<string, string>();
 
   const onMessage = (msg: ServerMessage): void => {
     switch (msg.t) {
@@ -365,7 +367,11 @@ async function start(): Promise<void> {
         onFx(msg.ev);
         break;
       case 'lobby':
-        lobbyPanel.update(msg.phase, msg.players, msg.countdown);
+        lobbyPanel.update(msg);
+        mapNames = new Map(msg.maps.map((m) => [m.id, m.name]));
+        break;
+      case 'mapPicked':
+        results.showPicked(mapNames.get(msg.mapId) ?? msg.mapId);
         break;
       case 'reject':
         setStatus(
@@ -412,7 +418,7 @@ async function start(): Promise<void> {
     () => conn.send(hello(choice.name, choice.characterId)),
     () => {
       setStatus(fr.disconnected);
-      lobbyPanel.update('playing', [], null); // hide it
+      lobbyPanel.update({ phase: 'playing', players: [], countdown: null, hostId: 0, rotation: { mode: 'all', selection: [] }, maps: [] }); // hide it
     },
   );
 

+ 66 - 0
src/renderer/style.css

@@ -674,3 +674,69 @@ input.invalid {
 #menu-play {
   margin-top: 12px;
 }
+
+/* ---------- Map rotation and catch-up pick ---------- */
+
+#lobby-panel .rotation {
+  text-align: left;
+  border-top: 2px dashed rgba(59, 36, 20, 0.3);
+  margin: 6px 0;
+  padding-top: 6px;
+  font-size: 13px;
+}
+
+#lobby-panel .rotation-title {
+  margin: 0 0 4px;
+  font-weight: bold;
+}
+
+#lobby-panel .rotation-summary,
+#lobby-panel .rotation-hint {
+  margin: 2px 0;
+}
+
+#lobby-panel .rotation-hint {
+  opacity: 0.7;
+  font-size: 12px;
+}
+
+#lobby-panel select {
+  font: inherit;
+  width: 100%;
+  border: 2px solid var(--ink);
+  border-radius: 8px;
+  padding: 2px 4px;
+  margin-bottom: 4px;
+}
+
+#lobby-panel label {
+  display: block;
+  cursor: pointer;
+}
+
+#round-hud .wind {
+  font-size: 14px;
+  font-weight: bold;
+  color: #2b7bbf;
+}
+
+#results .map-pick {
+  margin: 4px 0 10px;
+}
+
+#results .pick-title {
+  font-weight: bold;
+  margin: 4px 0 8px;
+}
+
+#results .pick-buttons {
+  display: flex;
+  gap: 8px;
+  justify-content: center;
+  flex-wrap: wrap;
+}
+
+#results .picked {
+  color: var(--pink-dark);
+  font-weight: bold;
+}

+ 37 - 2
src/renderer/ui/hud.ts

@@ -15,6 +15,7 @@ export class RoundHud {
   private readonly label = el('div', 'label');
   private readonly timer = el('div', 'timer');
   private readonly rush = el('div', 'rush-label', fr.hud.rush);
+  private readonly wind = el('div', 'wind');
   private readonly board = el('ul');
   private readonly hint = el('div');
   private lastKey = '';
@@ -23,7 +24,7 @@ export class RoundHud {
     this.banner.id = 'round-hud';
     this.board.id = 'scoreboard';
     this.hint.id = 'hint';
-    this.banner.append(this.label, this.timer, this.rush);
+    this.banner.append(this.label, this.timer, this.rush, this.wind);
     document.body.append(this.banner, this.board, this.hint);
     this.banner.hidden = true;
     this.board.hidden = true;
@@ -43,6 +44,10 @@ export class RoundHud {
     this.timer.textContent = `${Math.floor(t / 60)}:${String(t % 60).padStart(2, '0')}`;
     this.banner.classList.toggle('rush', state.finalRush);
     this.rush.hidden = !state.finalRush;
+    // Wind: up to three arrows in its direction.
+    const arrows = Math.min(3, Math.round(Math.abs(state.wind) / 150));
+    this.wind.hidden = arrows === 0;
+    this.wind.textContent = arrows === 0 ? '' : `💨 ${(state.wind > 0 ? '→' : '←').repeat(arrows)}`;
 
     // Rebuild the scoreboard only when something visible changed.
     const rows = [...players.values()]
@@ -69,8 +74,9 @@ export class RoundHud {
 export class ResultsScreen {
   private readonly root = el('div');
   private timer: ReturnType<typeof setInterval> | null = null;
+  private pickBox: HTMLDivElement | null = null;
 
-  constructor() {
+  constructor(private readonly onPick: (mapId: string) => void) {
     this.root.id = 'results';
     this.root.hidden = true;
     document.body.appendChild(this.root);
@@ -78,12 +84,41 @@ export class ResultsScreen {
 
   hide(): void {
     this.root.hidden = true;
+    this.pickBox = null;
     if (this.timer) clearInterval(this.timer);
     this.timer = null;
   }
 
   showRound(msg: RoundEndMessage, localId: number): void {
     this.show(fr.results.roundTitle(msg.round, msg.rounds), msg.results, localId, null, msg.nextIn, fr.results.next);
+    // Catch-up (readme §4): the last-placed player picks the next map.
+    if (msg.picker === null || msg.choices.length === 0) return;
+    const box = el('div', 'map-pick');
+    const pickerName = msg.results.find((r) => r.id === msg.picker)?.name ?? '?';
+    if (msg.picker === localId) {
+      box.appendChild(el('p', 'pick-title', fr.pick.you));
+      const buttons = el('div', 'pick-buttons');
+      for (const m of msg.choices) {
+        const b = el('button', 'secondary', m.name);
+        b.addEventListener('click', () => {
+          this.onPick(m.id);
+          for (const other of buttons.querySelectorAll('button')) other.disabled = true;
+          b.classList.replace('secondary', 'primary');
+        });
+        buttons.appendChild(b);
+      }
+      box.appendChild(buttons);
+    } else {
+      box.appendChild(el('p', 'pick-title', fr.pick.other(pickerName)));
+    }
+    this.pickBox = box;
+    this.root.querySelector('.footer')?.before(box);
+  }
+
+  /** Shows which map the picker chose. */
+  showPicked(mapName: string): void {
+    if (!this.pickBox) return;
+    this.pickBox.appendChild(el('p', 'picked', fr.pick.picked(mapName)));
   }
 
   showMatch(msg: MatchEndMessage, localId: number): void {

+ 63 - 6
src/renderer/ui/lobbyPanel.ts

@@ -1,24 +1,36 @@
-import type { LobbyPlayer, Phase } from '../../shared/protocol';
+import type { LobbyPlayer, MapSummary, Phase, Rotation } 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 type LobbyMessage = { phase: Phase; players: LobbyPlayer[]; countdown: number | null; hostId: number; rotation: Rotation; maps: MapSummary[] };
+
+const MODES: Rotation['mode'][] = ['all', 'random', 'selection'];
+
+/**
+ * Warm-up panel: who is here, who is ready, the start countdown, and the map rotation
+ * (set by the host, shown to everyone). Hidden during a match.
+ */
 export class LobbyPanel {
   private readonly root: HTMLDivElement;
   private readonly list: HTMLUListElement;
   private readonly status: HTMLParagraphElement;
   private readonly button: HTMLButtonElement;
+  private readonly rotationBox: HTMLDivElement;
   private ready = false;
+  /** Last rotation UI state rendered, to avoid rebuilding (and losing focus) on every lobby update. */
+  private rotationKey = '';
 
   constructor(
     private readonly localId: () => number,
     onReady: (ready: boolean) => void,
+    private readonly onRotation: (rotation: Rotation) => 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.root.innerHTML = `<h3>${fr.lobby.title}</h3><p class="hint">${fr.lobby.hint}</p><ul></ul><div class="rotation"></div><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 = this.root.querySelector('button.primary')!;
+    this.rotationBox = this.root.querySelector('.rotation')!;
     this.button.addEventListener('click', () => {
       onReady(!this.ready);
       this.button.blur(); // keep Space for jumping, not for re-clicking
@@ -27,7 +39,8 @@ export class LobbyPanel {
     document.body.appendChild(this.root);
   }
 
-  update(phase: Phase, players: LobbyPlayer[], countdown: number | null): void {
+  update(msg: LobbyMessage): void {
+    const { phase, players, countdown } = msg;
     this.root.hidden = phase !== 'lobby';
     if (phase !== 'lobby') return;
     this.ready = players.find((p) => p.id === this.localId())?.ready ?? false;
@@ -37,12 +50,56 @@ export class LobbyPanel {
       ...players.map((p) => {
         const li = document.createElement('li');
         li.className = p.ready ? 'ready' : '';
-        li.textContent = `${p.ready ? '✔' : '…'} ${p.name}`;
+        li.textContent = `${p.ready ? '✔' : '…'} ${p.name}${p.id === msg.hostId ? ' 👑' : ''}`;
         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);
+    this.renderRotation(msg);
+  }
+
+  private renderRotation(msg: LobbyMessage): void {
+    const isHost = msg.hostId === this.localId();
+    const key = JSON.stringify([isHost, msg.rotation, msg.maps]);
+    if (key === this.rotationKey) return;
+    this.rotationKey = key;
+    const box = this.rotationBox;
+    box.replaceChildren();
+    const title = document.createElement('p');
+    title.className = 'rotation-title';
+    title.textContent = fr.rotation.title;
+    box.appendChild(title);
+
+    const name = (id: string): string => msg.maps.find((m) => m.id === id)?.name ?? id;
+    if (!isHost) {
+      const p = document.createElement('p');
+      p.className = 'rotation-summary';
+      const picked = msg.rotation.mode === 'selection' && msg.rotation.selection.length > 0;
+      p.textContent = picked ? `${fr.rotation.modes.selection} : ${msg.rotation.selection.map(name).join(', ')}` : fr.rotation.modes[msg.rotation.mode];
+      box.append(p, Object.assign(document.createElement('p'), { className: 'rotation-hint', textContent: fr.rotation.hostOnly }));
+      return;
+    }
+
+    const select = document.createElement('select');
+    for (const mode of MODES) select.appendChild(new Option(fr.rotation.modes[mode], mode, false, mode === msg.rotation.mode));
+    select.addEventListener('change', () => this.onRotation({ mode: select.value as Rotation['mode'], selection: msg.rotation.selection }));
+    box.appendChild(select);
+
+    if (msg.rotation.mode === 'selection') {
+      for (const map of msg.maps) {
+        const label = document.createElement('label');
+        const check = document.createElement('input');
+        check.type = 'checkbox';
+        check.checked = msg.rotation.selection.includes(map.id);
+        check.addEventListener('change', () => {
+          const selection = msg.maps.map((m) => m.id).filter((id) => (id === map.id ? check.checked : msg.rotation.selection.includes(id)));
+          this.onRotation({ mode: 'selection', selection });
+        });
+        label.append(check, ` ${map.name}`);
+        box.appendChild(label);
+      }
+    }
   }
 }

+ 10 - 1
src/server/assets.ts

@@ -1,4 +1,4 @@
-import { existsSync, readFileSync } from 'node:fs';
+import { existsSync, readdirSync, readFileSync } from 'node:fs';
 import { join, resolve } from 'node:path';
 import { PNG } from 'pngjs';
 import { parseCharacterStats, type CharacterStats } from '../shared/characters';
@@ -49,6 +49,15 @@ export function readMapImage(assetsDir: string, mapId: string, file: 'background
   return readFileSync(join(assetsDir, 'maps', mapId, file));
 }
 
+/** Ids of every folder in assets/maps that has a map.json. */
+export function listMapIds(assetsDir: string): string[] {
+  const dir = join(assetsDir, 'maps');
+  return readdirSync(dir, { withFileTypes: true })
+    .filter((d) => d.isDirectory() && existsSync(join(dir, d.name, 'map.json')))
+    .map((d) => d.name)
+    .sort();
+}
+
 export interface LoadedMap {
   definition: MapDefinition;
   terrain: TerrainBitmap;

+ 30 - 0
src/server/game.ts

@@ -53,6 +53,7 @@ import {
   WHISK_CRACKS,
   WHISK_JOLT,
   WHISK_STUN_S,
+  WIND_GROUND_FACTOR,
   WOOL_BURN_RADIUS_FACTOR,
 } from '../shared/constants';
 import { Button, EMPTY_INPUT, isDown, type InputState } from '../shared/input';
@@ -65,6 +66,7 @@ import { DetachFinder, type DetachedComponent } from '../shared/terrain/detach';
 import { applyTerrainEvent, rasterizeChunk, type SequencedEvent, type TerrainEvent } from '../shared/terrain/events';
 import type { TerrainOp } from '../shared/terrain/ops';
 import { rleEncode } from '../shared/terrain/rle';
+import { MODIFIERS, type Modifier, type ModifierContext } from './modifiers';
 import { FallingChunk, type FixtureTag } from './physics/fallingChunk';
 import { TerrainColliders } from './physics/terrainColliders';
 import { MatchStats } from './stats';
@@ -215,6 +217,10 @@ export class Game {
   private itemTimer = 0;
   /** FX produced outside step() bookkeeping (hits, pickups), flushed into the step result. */
   private fx: FxEvent[] = [];
+  private readonly modifiers: Modifier[] = [];
+  private readonly modifierCtx: ModifierContext;
+  /** Side wind this tick (px/s²). */
+  private wind = 0;
 
   constructor(opts: GameOptions) {
     this.map = opts.map;
@@ -236,7 +242,18 @@ export class Game {
     if (this.rules) {
       this.spawnCherry();
       this.itemTimer = this.nextItemDelay();
+      // Map modifiers only run during rounds (ids are validated when the server builds its map catalog).
+      for (const id of this.map.modifiers) {
+        const make = MODIFIERS[id];
+        if (make) this.modifiers.push(make());
+      }
     }
+    this.modifierCtx = {
+      rng: this.rng,
+      terrain: this.terrain,
+      queueOp: (op) => this.queueOp(op),
+      setWind: (accel) => (this.wind = accel),
+    };
   }
 
   get events(): readonly SequencedEvent[] {
@@ -413,6 +430,7 @@ export class Game {
       custardY: this.custardY,
       timeLeft: this.rules ? Math.max(0, this.timeLeft) : null,
       finalRush: this.rules !== null && this.timeLeft <= FINAL_RUSH_S,
+      wind: this.wind,
       cherry,
     };
   }
@@ -580,6 +598,8 @@ export class Game {
     } else if (released & Button.Jump && vy < 0) {
       vy *= JUMP_CUT;
     }
+    // Wind: full push in the air, partial on the ground.
+    vx += this.wind * (p.grounded ? WIND_GROUND_FACTOR : 1) * TICK_DT;
     p.body.setLinearVelocity({ x: vx / PPM, y: vy / PPM });
 
     if (pressed & Button.Use && p.item === 'candle') this.throwCandle(p, input);
@@ -737,6 +757,16 @@ export class Game {
 
   private updateRound(): void {
     if (!this.rules) return;
+    for (const m of this.modifiers) m.tick(this.modifierCtx, TICK_DT);
+    if (this.wind !== 0) {
+      // Loose things drift too.
+      const push = (body: Body): void => {
+        const v = body.getLinearVelocity();
+        body.setLinearVelocity({ x: v.x + (this.wind * TICK_DT) / PPM, y: v.y });
+      };
+      for (const it of this.items.values()) push(it.body);
+      if (this.cherry && this.cherry.holder === 0) push(this.cherry.body);
+    }
     this.timeLeft -= TICK_DT;
     const c = this.map.custard;
     const speed = this.timeLeft <= FINAL_RUSH_S ? c.finalRushSpeed : c.speed;

+ 23 - 6
src/server/index.ts

@@ -3,9 +3,11 @@
 // Pacing (testing): --round=<seconds> --rounds=<n> --pause=<seconds between rounds and after the match>
 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 type { MapSummary } from '../shared/protocol';
+import { listMapIds, loadCharacterStats, loadMap, loadMaterials, resolveAssetsDir } from './assets';
 import { Announcer } from './discovery';
 import { Game, type RoundRules } from './game';
+import { isModifierId } from './modifiers';
 import { GameServer } from './net';
 import type { MatchStats } from './stats';
 
@@ -30,10 +32,24 @@ function main(): void {
   const seed = Number(arg('seed') ?? Date.now() % 0x7fffffff);
 
   const materials = loadMaterials(assetsDir);
-  // Validate the map once at startup: a broken map must fail here, not mid-game.
-  loadMap(assetsDir, mapId, materials);
-  const createGame = (gameSeed: number, rules: RoundRules | null, stats: MatchStats): Game => {
-    const { definition, terrain } = loadMap(assetsDir, mapId, materials);
+  // Validate every map once at startup (readme §6): a broken map is rejected with an explicit
+  // message and never offered, rather than failing mid-game.
+  const maps: MapSummary[] = [];
+  for (const id of listMapIds(assetsDir)) {
+    try {
+      const { definition } = loadMap(assetsDir, id, materials);
+      const unknown = definition.modifiers.filter((m) => !isModifierId(m));
+      if (unknown.length > 0) throw new Error(`Map "${id}" rejected: unknown modifier(s) ${unknown.join(', ')}`);
+      maps.push({ id, name: definition.name });
+    } catch (err) {
+      console.error(`[server] ${err instanceof Error ? err.message : String(err)}`);
+    }
+  }
+  // --map puts a map first, so it is the warm-up map.
+  maps.sort((a, b) => Number(b.id === mapId) - Number(a.id === mapId));
+  if (maps.length === 0) throw new Error('No valid map in assets/maps');
+  const createGame = (gameSeed: number, rules: RoundRules | null, stats: MatchStats, gameMap: string): Game => {
+    const { definition, terrain } = loadMap(assetsDir, gameMap, materials);
     return new Game({ map: definition, terrain, materials, seed: gameSeed, rules, stats, loadCharacter: (id) => loadCharacterStats(assetsDir, id) });
   };
 
@@ -43,6 +59,7 @@ function main(): void {
   const pause = num('pause');
   const server = new GameServer({
     createGame,
+    maps,
     seed,
     port,
     roundDuration: num('round'),
@@ -50,7 +67,7 @@ function main(): void {
     roundEndPause: pause,
     matchEndPause: pause,
     onListening: () => {
-      console.log(`[server] "${name}" on map "${mapId}" (seed ${seed}) listening on :${port}`);
+      console.log(`[server] "${name}" with ${maps.length} map(s) [${maps.map((m) => m.id).join(', ')}] (seed ${seed}) listening on :${port}`);
       announcer = new Announcer(() => ({
         magic: 'goutermageddon',
         serverId,

+ 29 - 0
src/server/modifiers/index.ts

@@ -0,0 +1,29 @@
+import type { Rng } from '../../shared/rng';
+import type { TerrainBitmap } from '../../shared/terrain/bitmap';
+import type { TerrainOp } from '../../shared/terrain/ops';
+import { randomCracks } from './randomCracks';
+import { wind } from './wind';
+
+/** What a map modifier may do to a round. Implemented by the server Game. */
+export interface ModifierContext {
+  readonly rng: Rng;
+  readonly terrain: TerrainBitmap;
+  queueOp(op: TerrainOp): void;
+  /** Sideways push for this tick (px/s²): full strength in the air, reduced on the ground. */
+  setWind(accel: number): void;
+}
+
+/** A special rule of a map (readme §6), active during rounds, not the warm-up. */
+export interface Modifier {
+  tick(ctx: ModifierContext, dt: number): void;
+}
+
+/** Map modifier ids, as written in map.json "modifiers". */
+export const MODIFIERS: Record<string, () => Modifier> = {
+  wind,
+  'random-cracks': randomCracks,
+};
+
+export function isModifierId(id: string): boolean {
+  return Object.hasOwn(MODIFIERS, id);
+}

+ 34 - 0
src/server/modifiers/randomCracks.ts

@@ -0,0 +1,34 @@
+import { CRACK_INTERVAL_S, CRACK_LENGTH, CRACK_POWER, CRACK_RADIUS, CRACK_STEP } from '../../shared/constants';
+import type { Modifier } from './index';
+
+/** Materials that crack on their own (brittle: biscuit). */
+const BRITTLE = 5;
+const TRIES = 80;
+
+/** Random cracks (La Tarte aux Fruits): now and then a zigzag crack opens in brittle material. */
+export function randomCracks(): Modifier {
+  let timer = -1;
+  return {
+    tick(ctx, dt) {
+      if (timer < 0) timer = ctx.rng.range(CRACK_INTERVAL_S[0], CRACK_INTERVAL_S[1]);
+      timer -= dt;
+      if (timer > 0) return;
+      timer = ctx.rng.range(CRACK_INTERVAL_S[0], CRACK_INTERVAL_S[1]);
+      const { width, height, data } = ctx.terrain;
+      for (let t = 0; t < TRIES; t++) {
+        let x = ctx.rng.int(0, width - 1);
+        let y = ctx.rng.int(0, height - 1);
+        if (data[y * width + x] !== BRITTLE) continue;
+        // A mostly vertical zigzag: it cuts through the brittle sheets.
+        let angle = Math.PI / 2 + ctx.rng.range(-0.6, 0.6);
+        for (let i = 0; i < CRACK_LENGTH; i++) {
+          ctx.queueOp({ kind: 'carve', x: Math.round(x), y: Math.round(y), r: CRACK_RADIUS, power: CRACK_POWER });
+          angle += ctx.rng.range(-0.7, 0.7);
+          x += Math.cos(angle) * CRACK_STEP;
+          y += Math.sin(angle) * CRACK_STEP;
+        }
+        return;
+      }
+    },
+  };
+}

+ 21 - 0
src/server/modifiers/wind.ts

@@ -0,0 +1,21 @@
+import { WIND_MAX, WIND_PERIOD_S } from '../../shared/constants';
+import type { Modifier } from './index';
+
+/** Side wind (La Pile de Crêpes): a gust picks a new strength and direction every few seconds. */
+export function wind(): Modifier {
+  let current = 0;
+  let target = 0;
+  let timer = 0;
+  return {
+    tick(ctx, dt) {
+      timer -= dt;
+      if (timer <= 0) {
+        timer = WIND_PERIOD_S;
+        target = ctx.rng.range(-WIND_MAX, WIND_MAX);
+      }
+      // Ease toward the new gust so it never snaps.
+      current += (target - current) * Math.min(1, dt * 1.5);
+      ctx.setWind(current);
+    },
+  };
+}

+ 74 - 11
src/server/net.ts

@@ -15,9 +15,18 @@ import {
   TICK_RATE,
   TICKS_PER_SNAPSHOT,
 } from '../shared/constants';
-import { isClientMessage, type LobbyPlayer, type Phase, type RoundResult, type ServerMessage } from '../shared/protocol';
+import {
+  isClientMessage,
+  type LobbyPlayer,
+  type MapSummary,
+  type Phase,
+  type RoundResult,
+  type Rotation,
+  type ServerMessage,
+} from '../shared/protocol';
 import { Rng } from '../shared/rng';
 import type { Game, RoundRules } from './game';
+import { DEFAULT_ROTATION, defaultMap, lastPlaced, rotationPool, sanitizeRotation } from './rotation';
 import { MatchStats } from './stats';
 
 const packr = new Packr({ useRecords: false });
@@ -29,8 +38,10 @@ interface Client {
 }
 
 export interface GameServerOptions {
-  /** Builds a fresh game on the map: warm-up when `rules` is null, a round otherwise. */
-  createGame: (seed: number, rules: RoundRules | null, stats: MatchStats) => Game;
+  /** Builds a fresh game on `mapId`: warm-up when `rules` is null, a round otherwise. */
+  createGame: (seed: number, rules: RoundRules | null, stats: MatchStats, mapId: string) => Game;
+  /** Valid maps (see index.ts: rejected maps never get here). Must not be empty. */
+  maps: MapSummary[];
   seed: number;
   port: number;
   host?: string;
@@ -63,12 +74,17 @@ export class GameServer {
   private stats = new MatchStats();
   /** Match totals by player id. */
   private totals = new Map<number, number>();
+  private rotation: Rotation = DEFAULT_ROTATION;
+  /** Who picks the next map during the current round results, and what they picked. */
+  private picker: number | null = null;
+  private pickedMap: string | null = null;
   private nextPlayerId = 1;
   private timer: NodeJS.Timeout | null = null;
 
   constructor(private readonly opts: GameServerOptions) {
+    if (opts.maps.length === 0) throw new Error('No playable map');
     this.rng = new Rng(opts.seed);
-    this.game = this.newGame(null);
+    this.game = this.newGame(null, this.warmupMap());
     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) => {
@@ -164,8 +180,19 @@ export class GameServer {
     return [...this.clients].flatMap((c) => (c.player ? [c.player] : []));
   }
 
-  private newGame(rules: RoundRules | null): Game {
-    return this.opts.createGame(this.rng.nextU32(), rules, this.stats);
+  private newGame(rules: RoundRules | null, mapId: string): Game {
+    return this.opts.createGame(this.rng.nextU32(), rules, this.stats, mapId);
+  }
+
+  /** The warm-up happens on the first map of the rotation. */
+  private warmupMap(): string {
+    return rotationPool(this.rotation, this.opts.maps)[0].id;
+  }
+
+  /** The first player who joined (and is still here) sets the rotation. */
+  private hostId(): number {
+    const ids = this.players().map((p) => p.id);
+    return ids.length > 0 ? Math.min(...ids) : 0;
   }
 
   private onConnection(socket: WebSocket): void {
@@ -183,6 +210,8 @@ export class GameServer {
       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 === 'rotation') this.onRotation(client.player, msg.rotation);
+      else if (msg.t === 'pickMap') this.onPickMap(client.player, msg.mapId);
       else if (msg.t === 'desync') {
         console.error(
           `[net] DESYNC: player ${client.player.id} (${client.player.name}) has hash ${msg.hash} at seq ${msg.seq}; ` +
@@ -200,7 +229,7 @@ export class GameServer {
         this.phase = 'lobby';
         this.countdown = null;
         this.round = 0;
-        this.game = this.newGame(null);
+        this.game = this.newGame(null, this.warmupMap());
       }
       this.updateCountdown();
       this.broadcastLobby();
@@ -245,6 +274,25 @@ export class GameServer {
     this.broadcastLobby();
   }
 
+  private onRotation(player: LobbyPlayer, rotation: Rotation): void {
+    if (this.phase !== 'lobby' || player.id !== this.hostId()) return;
+    this.rotation = sanitizeRotation(rotation, this.opts.maps);
+    // Warm up on a map of the new rotation.
+    if (this.game.map.id !== this.warmupMap()) {
+      this.game = this.newGame(null, this.warmupMap());
+      this.populateAndAnnounce();
+      return;
+    }
+    this.broadcastLobby();
+  }
+
+  private onPickMap(player: LobbyPlayer, mapId: string): void {
+    if (this.phase !== 'roundEnd' || player.id !== this.picker || this.pickedMap !== null) return;
+    if (!rotationPool(this.rotation, this.opts.maps).some((m) => m.id === mapId)) return;
+    this.pickedMap = mapId;
+    this.broadcast({ t: 'mapPicked', mapId, by: player.id });
+  }
+
   private updateCountdown(): void {
     const players = this.players();
     const allReady = this.phase === 'lobby' && players.length > 0 && players.every((p) => p.ready);
@@ -265,7 +313,10 @@ export class GameServer {
   private startRound(): void {
     this.round++;
     this.phase = 'playing';
-    this.game = this.newGame({ duration: this.opts.roundDuration ?? ROUND_DURATION_S });
+    const mapId = this.pickedMap ?? defaultMap(this.rotation, this.opts.maps, this.round, this.rng);
+    this.picker = null;
+    this.pickedMap = null;
+    this.game = this.newGame({ duration: this.opts.roundDuration ?? ROUND_DURATION_S }, mapId);
     this.populateAndAnnounce();
   }
 
@@ -282,7 +333,11 @@ export class GameServer {
       const nextIn = this.opts.roundEndPause ?? ROUND_END_PAUSE_S;
       this.phase = 'roundEnd';
       this.pause = Math.max(1, Math.round(nextIn * TICK_RATE));
-      this.broadcast({ t: 'roundEnd', round: this.round, rounds: this.rounds, results, nextIn });
+      // Catch-up rule (readme §4): the last-placed player picks the next map.
+      this.picker = lastPlaced(results);
+      this.pickedMap = null;
+      const choices = rotationPool(this.rotation, this.opts.maps);
+      this.broadcast({ t: 'roundEnd', round: this.round, rounds: this.rounds, results, nextIn, picker: this.picker, choices });
     }
     this.broadcastLobby();
   }
@@ -297,7 +352,7 @@ export class GameServer {
   private backToLobby(): void {
     this.phase = 'lobby';
     this.round = 0;
-    this.game = this.newGame(null);
+    this.game = this.newGame(null, this.warmupMap());
     this.populateAndAnnounce();
   }
 
@@ -309,7 +364,15 @@ export class GameServer {
 
   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 });
+    this.broadcast({
+      t: 'lobby',
+      phase: this.phase,
+      players: this.players(),
+      countdown,
+      hostId: this.hostId(),
+      rotation: this.rotation,
+      maps: this.opts.maps,
+    });
   }
 
   private send(client: Client, msg: ServerMessage): void {

+ 33 - 0
src/server/rotation.ts

@@ -0,0 +1,33 @@
+import type { Rng } from '../shared/rng';
+import type { MapSummary, RoundResult, Rotation } from '../shared/protocol';
+
+export const DEFAULT_ROTATION: Rotation = { mode: 'all', selection: [] };
+
+/** Maps a match may use: the host's selection (ignoring unknown ids), or every map. Never empty. */
+export function rotationPool(rotation: Rotation, catalog: MapSummary[]): MapSummary[] {
+  if (rotation.mode === 'selection') {
+    const picked = catalog.filter((m) => rotation.selection.includes(m.id));
+    if (picked.length > 0) return picked;
+  }
+  return catalog;
+}
+
+/** The map for round `round` (1-based) when nobody picks: in order, or at random for 'random'. */
+export function defaultMap(rotation: Rotation, catalog: MapSummary[], round: number, rng: Rng): string {
+  const pool = rotationPool(rotation, catalog);
+  if (rotation.mode === 'random') return pool[rng.int(0, pool.length - 1)].id;
+  return pool[(Math.max(1, round) - 1) % pool.length].id;
+}
+
+/** The player who picks the next map: lowest total, then lowest round score, then latest to join. */
+export function lastPlaced(results: RoundResult[]): number | null {
+  if (results.length === 0) return null;
+  const sorted = [...results].sort((a, b) => a.total - b.total || a.roundScore - b.roundScore || b.id - a.id);
+  return sorted[0].id;
+}
+
+/** Keeps only known map ids and a known mode. */
+export function sanitizeRotation(rotation: Rotation, catalog: MapSummary[]): Rotation {
+  const ids = new Set(catalog.map((m) => m.id));
+  return { mode: rotation.mode, selection: [...new Set(rotation.selection.filter((id) => ids.has(id)))] };
+}

+ 13 - 1
src/shared/constants.ts

@@ -2,7 +2,7 @@
 // (characters, maps, materials) whenever they belong to a specific entity.
 
 /** Bumped on every incompatible protocol change. */
-export const PROTOCOL_VERSION = 6;
+export const PROTOCOL_VERSION = 7;
 
 export const NET_PORT = 47100;
 export const DISCOVERY_PORT = 47101;
@@ -59,6 +59,18 @@ export const WHISK_STUN_S = 0.35;
 /** Explosions burn wool (material 7) within this multiple of their radius (readme §4, Mamie Tricot). */
 export const WOOL_BURN_RADIUS_FACTOR = 2.2;
 export const MATERIAL_WOOL = 7;
+// Map modifiers (server/modifiers). Tune by playing.
+/** Strongest side wind (px/s²) and how often a new gust is drawn (s). */
+export const WIND_MAX = 520;
+export const WIND_PERIOD_S = 6;
+/** Wind push on a player standing on the ground, relative to the air. */
+export const WIND_GROUND_FACTOR = 0.35;
+/** Random cracks: delay between two cracks (s), carve size and shape. */
+export const CRACK_INTERVAL_S: [number, number] = [3, 7];
+export const CRACK_LENGTH = 14;
+export const CRACK_STEP = 6;
+export const CRACK_RADIUS = 3;
+export const CRACK_POWER = 35;
 /** Projectiles can only hit things after this delay, so they do not blow up in the thrower's hands (s). */
 export const PROJECTILE_ARM_S = 0.08;
 

+ 52 - 2
src/shared/protocol.ts

@@ -59,6 +59,8 @@ export interface RoundState {
   timeLeft: number | null;
   /** True during the last FINAL_RUSH_S seconds. */
   finalRush: boolean;
+  /** Sideways wind (px/s², positive = to the right), 0 without the wind modifier. */
+  wind: number;
   cherry: CherryState | null;
 }
 
@@ -104,6 +106,21 @@ export interface ChunkInfo {
   state: ChunkState;
 }
 
+/** A playable map, as listed in the lobby. */
+export interface MapSummary {
+  id: string;
+  name: string;
+}
+
+/**
+ * Which maps a match uses (readme §6): all of them in order, a selection (in order),
+ * or all of them in random order. The last-placed player still picks from this pool.
+ */
+export interface Rotation {
+  mode: 'all' | 'selection' | 'random';
+  selection: string[];
+}
+
 export const PHASES = ['lobby', 'playing', 'roundEnd', 'matchEnd'] as const;
 export type Phase = (typeof PHASES)[number];
 
@@ -151,6 +168,10 @@ export type ClientMessage =
   | { t: 'hello'; version: number; name: string; characterId: string }
   | ({ t: 'input' } & InputState)
   | { t: 'ready'; ready: boolean }
+  /** Host only, in the lobby. */
+  | { t: 'rotation'; rotation: Rotation }
+  /** The last-placed player picks the next map during the round results. */
+  | { t: 'pickMap'; mapId: string }
   /** The client's bitmap hash differs from the server's at `seq`. */
   | { t: 'desync'; seq: number; hash: number };
 
@@ -179,10 +200,29 @@ export type ServerMessage =
   | { t: 'crushed'; id: number; x: number; y: number }
   | { t: 'snapshot'; tick: number; players: PlayerState[]; chunks: ChunkState[]; items: ItemState[]; round: RoundState }
   | { t: 'fx'; ev: FxEvent }
-  | { t: 'lobby'; phase: Phase; players: LobbyPlayer[]; countdown: number | null }
+  | {
+      t: 'lobby';
+      phase: Phase;
+      players: LobbyPlayer[];
+      countdown: number | null;
+      /** The first player who joined sets the rotation. */
+      hostId: number;
+      rotation: Rotation;
+      maps: MapSummary[];
+    }
+  | { t: 'mapPicked'; mapId: string; by: number }
   /** A round (or the warm-up, round 0) starts on a freshly loaded map: clients drop their world and reload. */
   | { t: 'start'; mapId: string; seed: number; players: PlayerInfo[]; round: number; rounds: number }
-  | { t: 'roundEnd'; round: number; rounds: number; results: RoundResult[]; nextIn: number }
+  | {
+      t: 'roundEnd';
+      round: number;
+      rounds: number;
+      results: RoundResult[];
+      nextIn: number;
+      /** Last-placed player, who picks the next map among `choices` (readme §4). */
+      picker: number | null;
+      choices: MapSummary[];
+    }
   | { t: 'matchEnd'; results: RoundResult[]; trophies: Trophy[]; lobbyIn: number }
   /** Consistency check: the server's bitmap hash once `seq` terrain events have been applied. */
   | { t: 'hash'; seq: number; hash: number };
@@ -197,6 +237,16 @@ export function isClientMessage(msg: unknown): msg is ClientMessage {
   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 === 'pickMap') return typeof m.mapId === 'string';
+  if (m.t === 'rotation') {
+    const r = m.rotation as Partial<Rotation> | undefined;
+    return (
+      !!r &&
+      (r.mode === 'all' || r.mode === 'selection' || r.mode === 'random') &&
+      Array.isArray(r.selection) &&
+      r.selection.every((id) => typeof id === 'string')
+    );
+  }
   if (m.t === 'desync') return typeof m.seq === 'number' && typeof m.hash === 'number';
   return false;
 }

+ 107 - 0
tests/maps.test.ts

@@ -0,0 +1,107 @@
+import { describe, expect, it } from 'vitest';
+import { listMapIds, loadCharacterStats, loadMap } from '../src/server/assets';
+import { Game } from '../src/server/game';
+import { isModifierId } from '../src/server/modifiers';
+import { defaultMap, lastPlaced, rotationPool, sanitizeRotation } from '../src/server/rotation';
+import { TICK_RATE } from '../src/shared/constants';
+import { Rng } from '../src/shared/rng';
+import type { MapSummary } from '../src/shared/protocol';
+import { ASSETS, realMaterials } from './helpers';
+
+const materials = realMaterials();
+const catalog: MapSummary[] = [
+  { id: 'a', name: 'A' },
+  { id: 'b', name: 'B' },
+  { id: 'c', name: 'C' },
+];
+
+describe('built-in maps', () => {
+  it('all load, validate, and only use known modifiers', () => {
+    const ids = listMapIds(ASSETS);
+    expect(ids).toEqual(expect.arrayContaining(['gateau-etages', 'mille-feuille', 'piece-montee']));
+    for (const id of ids) {
+      const { definition, terrain } = loadMap(ASSETS, id, materials);
+      expect(terrain.width).toBe(definition.size[0]);
+      for (const m of definition.modifiers) expect(isModifierId(m), `${id}: ${m}`).toBe(true);
+    }
+  });
+
+  it('la pièce montée: removing a chou drops the column above it', () => {
+    const { definition, terrain } = loadMap(ASSETS, 'piece-montee', materials);
+    const game = new Game({ map: definition, terrain, materials, seed: 1, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
+    // Bottom row, centre chou: centre (1024, 905 - 42).
+    game.queueOp({ kind: 'carve', x: 1024, y: 863, r: 44, power: 99 });
+    const res = game.step();
+    const heights = res.terrain.flatMap((e) => (e.ev.kind === 'detach' && !e.ev.debris ? [e.ev.h] : []));
+    expect(heights.length).toBeGreaterThanOrEqual(1);
+    expect(Math.max(...heights)).toBeGreaterThan(300); // the whole column, not just one chou
+  });
+});
+
+describe('rotation', () => {
+  it('pool: the selection when valid, every map otherwise', () => {
+    expect(rotationPool({ mode: 'all', selection: ['b'] }, catalog)).toEqual(catalog);
+    expect(rotationPool({ mode: 'selection', selection: ['c', 'a'] }, catalog).map((m) => m.id)).toEqual(['a', 'c']);
+    expect(rotationPool({ mode: 'selection', selection: ['zzz'] }, catalog)).toEqual(catalog);
+    expect(sanitizeRotation({ mode: 'selection', selection: ['a', 'zzz', 'a'] }, catalog)).toEqual({ mode: 'selection', selection: ['a'] });
+  });
+
+  it('default map: in order, or seeded random', () => {
+    const rng = new Rng(1);
+    expect([1, 2, 3, 4].map((r) => defaultMap({ mode: 'all', selection: [] }, catalog, r, rng))).toEqual(['a', 'b', 'c', 'a']);
+    const pick = (): string[] => [1, 2, 3, 4, 5].map((r) => defaultMap({ mode: 'random', selection: [] }, catalog, r, new Rng(9 + r)));
+    expect(pick()).toEqual(pick());
+  });
+
+  it('the last-placed player picks', () => {
+    expect(
+      lastPlaced([
+        { id: 1, name: '', characterId: '', roundScore: 5, total: 20 },
+        { id: 2, name: '', characterId: '', roundScore: 1, total: 3 },
+        { id: 3, name: '', characterId: '', roundScore: 9, total: 3 },
+      ]),
+    ).toBe(2);
+    expect(lastPlaced([])).toBeNull();
+  });
+});
+
+describe('modifiers', () => {
+  function round(mapId: string, seed = 3): Game {
+    const { definition, terrain } = loadMap(ASSETS, mapId, materials);
+    return new Game({ map: definition, terrain, materials, seed, rules: { duration: 180 }, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
+  }
+
+  it('wind blows players sideways (piece-montee)', () => {
+    const game = round('piece-montee');
+    const { id } = game.addPlayer('Plume', 'generic');
+    game.teleport(id, 300, 300);
+    let maxWind = 0;
+    const x0 = game.snapshot()[0].x;
+    let drift = 0;
+    for (let i = 0; i < 12 * TICK_RATE; i++) {
+      game.step();
+      maxWind = Math.max(maxWind, Math.abs(game.roundState().wind));
+      if (i === 30) drift = game.snapshot().find((p) => p.id === id)!.x - x0;
+    }
+    expect(maxWind).toBeGreaterThan(50);
+    expect(Math.abs(drift)).toBeGreaterThan(0);
+  });
+
+  it('random cracks open in the brittle pastry over time (mille-feuille), deterministically', () => {
+    const a = round('mille-feuille', 4);
+    const b = round('mille-feuille', 4);
+    for (let i = 0; i < 10 * TICK_RATE; i++) {
+      a.step();
+      b.step();
+    }
+    expect(a.events.length).toBeGreaterThan(10);
+    expect(a.terrain.hash()).toBe(b.terrain.hash());
+  });
+
+  it('modifiers are off during the warm-up', () => {
+    const { definition, terrain } = loadMap(ASSETS, 'mille-feuille', materials);
+    const warm = new Game({ map: definition, terrain, materials, seed: 4, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
+    for (let i = 0; i < 10 * TICK_RATE; i++) warm.step();
+    expect(warm.events).toHaveLength(0);
+  });
+});

+ 62 - 3
tests/net.test.ts

@@ -21,6 +21,7 @@ class TestClient {
   readonly messages: ServerMessage[] = [];
   readonly socket: WebSocket;
   terrain = freshTerrain();
+  mapId = 'gateau-etages';
   lastSeq = 0;
   playerId = -1;
   hashChecks: { seq: number; server: number; mine: number }[] = [];
@@ -49,7 +50,8 @@ class TestClient {
       this.playerId = msg.playerId;
       for (const { seq, ev } of msg.events) this.apply(seq, ev);
     } else if (msg.t === 'start') {
-      this.terrain = freshTerrain();
+      this.mapId = msg.mapId;
+      this.terrain = loadMap(ASSETS, msg.mapId, materials).terrain;
       this.lastSeq = 0;
     } else if (msg.t === 'terrain') {
       this.apply(msg.seq, msg.ev);
@@ -80,9 +82,14 @@ describe('LAN server (M3)', () => {
   async function startServer(pacing: Partial<GameServerOptions> = {}): Promise<GameServer> {
     await new Promise<void>((resolve) => {
       server = new GameServer({
+        maps: [
+          { id: 'gateau-etages', name: 'Le Gâteau à Étages' },
+          { id: 'mille-feuille', name: 'Le Mille-Feuille' },
+          { id: 'piece-montee', name: 'La Pièce Montée' },
+        ],
         ...pacing,
-        createGame: (seed, rules, stats) => {
-          const { definition, terrain } = loadMap(ASSETS, 'gateau-etages', materials);
+        createGame: (seed, rules, stats, mapId) => {
+          const { definition, terrain } = loadMap(ASSETS, mapId, materials);
           return new Game({ map: definition, terrain, materials, seed, rules, stats, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
         },
         seed: 42,
@@ -218,4 +225,56 @@ describe('LAN server (M3)', () => {
     expect(a.last('lobby')).toMatchObject({ phase: 'lobby' });
     expect(a.last('lobby')!.players[0].ready).toBe(false);
   });
+
+  it('only the host sets the rotation; the warm-up moves to its first map', async () => {
+    const s = await startServer();
+    const host = new TestClient(s.port);
+    const guest = new TestClient(s.port);
+    clients.push(host, guest);
+    await host.hello('Hote');
+    await guest.hello('Invite');
+    await flush();
+    expect(guest.last('lobby')).toMatchObject({ hostId: host.playerId, rotation: { mode: 'all' } });
+    expect(guest.last('lobby')!.maps.map((m) => m.id)).toEqual(['gateau-etages', 'mille-feuille', 'piece-montee']);
+
+    guest.send({ t: 'rotation', rotation: { mode: 'selection', selection: ['piece-montee'] } });
+    await flush();
+    expect(host.last('lobby')!.rotation.mode).toBe('all');
+
+    host.send({ t: 'rotation', rotation: { mode: 'selection', selection: ['piece-montee', 'nope'] } });
+    await flush(400); // the warm-up map is reloaded: give the server time to decode it
+    expect(guest.last('lobby')!.rotation).toEqual({ mode: 'selection', selection: ['piece-montee'] });
+    expect(guest.last('start')).toMatchObject({ mapId: 'piece-montee', round: 0 });
+  });
+
+  it('the last-placed player picks the next map during the round results', async () => {
+    const s = await startServer({ rounds: 2, roundDuration: 1, roundEndPause: 1, matchEndPause: 0.25 });
+    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();
+    a.send({ t: 'ready', ready: true });
+    b.send({ t: 'ready', ready: true });
+    await flush();
+    tick(START_COUNTDOWN_S * TICK_RATE);
+    await flush();
+    expect(a.last('start')).toMatchObject({ round: 1, mapId: 'gateau-etages' });
+    tick(TICK_RATE + 2);
+    await flush();
+    const end = a.last('roundEnd')!;
+    // Nobody scored: the tie goes to the latest to join.
+    expect(end.picker).toBe(b.playerId);
+    expect(end.choices).toHaveLength(3);
+
+    a.send({ t: 'pickMap', mapId: 'piece-montee' }); // not Alice's call
+    b.send({ t: 'pickMap', mapId: 'mille-feuille' });
+    await flush();
+    expect(a.last('mapPicked')).toEqual({ t: 'mapPicked', mapId: 'mille-feuille', by: b.playerId });
+    tick(TICK_RATE);
+    await flush();
+    expect(a.last('start')).toMatchObject({ round: 2, mapId: 'mille-feuille' });
+    expect(b.mapId).toBe('mille-feuille');
+  });
 });