Explorar o código

feat(rules): cherry, rising custard, spoon catapult, items, rounds and trophies (M4)

- Round rules in the server Game: the custard rises (faster in the final
  30 s); falling in or being crushed takes a player out for 5 s, then a spoon
  catapult launches them from a column of open air toward the closest spawn
  (momentum kept during the flight). The cherry scores 1 point per second;
  a hit makes its holder drop it, with a short regrab delay.
- Items fall from the sky (seeded): candle (carried, thrown with F, explodes:
  carves, chocolate resists, blows everyone away), Smarties (speed boost),
  electric whisk (jolts the others, shakes jelly loose).
- Match flow in GameServer: warm-up lobby, 4 rounds of 3 min on fresh maps,
  round results, final results with trophies from server/stats.ts, back to
  the lobby. Pacing flags on the headless server for testing.
- Client: custard, cherry, items, held candle, spoon, splash/explosion/
  pickup effects, round HUD (timer, final rush, scoreboard) and result
  screens. Protocol v5.
- Tests: custard, death and spoon landing over several seeds, cherry
  pickup/score/drop, candle, Smarties, whisk, seeded item drops, trophies,
  and a full accelerated match over WebSocket.

No new dependency.

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

+ 7 - 0
readme.md

@@ -39,6 +39,7 @@ Pas de nouvelle dépendance sans justification dans le message de commit.
 npm install
 npm run dev            # Electron en dev (HMR renderer)
 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
@@ -293,6 +294,12 @@ Chaque étape doit être **jouable** avant de passer à la suivante.
  
 - Cerise, score, crème qui monte, cuillère de réapparition, objets, manches, partie, écran de score.
 - **Terminé quand :** une partie complète se joue du lobby aux trophées.
+- **État :** implémenté.
+  - **Échauffement :** la carte sert de salle d'attente. Pas de chrono, de cerise ni d'objets, et la crème ne monte pas, mais on peut y tomber.
+  - **Mort :** tomber dans la crème ou être écrasé par un bloc. On revient 5 s plus tard par la cuillère, lancée depuis une colonne de vide vers le point d'apparition le plus proche.
+  - **Objets :** Smarties et fouet agissent au ramassage. La bougie se garde et se lance avec **F** ; son explosion touche tout le monde, le lanceur compris.
+  - **Trophées** (`server/stats.ts`) : Le Glouton, Le Kamikaze, Le Promoteur Immobilier, Pas Sa Faute, L'Artificier, Le Cueilleur.
+  - **Reste en M7 :** le rattrapage (le dernier choisit la carte et impose un personnage).
 ### M5 : Trois personnages
  
 - Castor Glouton (déjà jouable depuis M1), Mamie Tricot, Pyromanchot (le trio creuser / construire / exploser).

+ 4 - 0
src/renderer/config.ts

@@ -24,6 +24,10 @@ export const HIT_SHAKE = 8;
 export const MERGE_SHAKE = 6;
 export const CRUSH_SHAKE = 12;
 export const PLAYER_SPLAT_COLOR = 0xff6f91;
+export const CUSTARD_COLOR = 0xf7d774;
+export const CUSTARD_DARK = 0xd9a93a;
+export const EXPLOSION_SHAKE = 14;
+export const WHISK_SHAKE = 10;
 export const FREEZE_FRAME_MS = 50;
 export const PARTICLES_PER_100_PX = 6;
 export const PARTICLE_MAX = 700;

+ 13 - 1
src/renderer/entities/PlayerView.ts

@@ -2,6 +2,7 @@ import { Container, Graphics, Text } from 'pixi.js';
 import { Anim } from '../../shared/characters';
 import type { PlayerInfo, PlayerState } from '../../shared/protocol';
 import { PAINTERS, paintGeneric, type Painter, type Pose } from '../characters/painters';
+import { paintItem } from './RoundViews';
 import { OUTLINE_COLOR, OUTLINE_WIDTH, PLAYER_COLORS, SQUASH_RECOVERY } from '../config';
 
 /** Max size the fat ratio is normalized against (castor maxSize is in its JSON; this only drives the cheeks). */
@@ -17,6 +18,8 @@ export interface PlayerEvents {
 export class PlayerView {
   readonly container = new Container();
   private readonly body = new Graphics();
+  /** Carried item (candle) held up next to the character. */
+  private readonly held = new Graphics();
   private readonly name: Text;
   private readonly paint: Painter;
   private readonly color: number;
@@ -35,7 +38,8 @@ export class PlayerView {
       style: { fontFamily: 'Comic Sans MS', fontSize: 15, fill: this.color, fontWeight: 'bold', stroke: { color: OUTLINE_COLOR, width: 4 } },
     });
     this.name.anchor.set(0.5, 1);
-    this.container.addChild(this.body, this.name);
+    this.held.scale.set(0.8);
+    this.container.addChild(this.body, this.held, this.name);
   }
 
   /** Draws a static pose (menu portraits). */
@@ -47,6 +51,14 @@ export class PlayerView {
     this.time += dt;
     const size = state.size;
     this.container.position.set(state.x, state.y);
+    // In the custard, waiting for the spoon.
+    this.container.visible = (state.anim & Anim.Dead) === 0;
+    this.held.clear();
+    if (state.item) {
+      paintItem(this.held, state.item, false, this.time);
+      this.held.position.set(-state.facing * size * 0.95, -size * 0.35);
+      this.held.rotation = -state.facing * 0.4;
+    }
 
     // Landing squashes, jumping stretches.
     const events: PlayerEvents = { landed: false, landingSpeed: 0, stunnedNow: false };

+ 163 - 0
src/renderer/entities/RoundViews.ts

@@ -0,0 +1,163 @@
+import { Container, Graphics } from 'pixi.js';
+import type { CherryState, ItemKind, ItemState } from '../../shared/protocol';
+import { CUSTARD_COLOR, CUSTARD_DARK, OUTLINE_COLOR, OUTLINE_WIDTH } from '../config';
+
+const INK = OUTLINE_COLOR;
+const W = OUTLINE_WIDTH;
+
+/** The rising custard: a wobbly yellow sea with a felt outline and lazy bubbles. Drawn above players. */
+export class CustardView {
+  readonly graphics = new Graphics();
+  private time = 0;
+
+  update(level: number, mapWidth: number, mapHeight: number, dt: number, rush: boolean): void {
+    this.time += dt * (rush ? 2.2 : 1);
+    const g = this.graphics;
+    g.clear();
+    const bottom = mapHeight + 400;
+    const pts: number[] = [];
+    for (let x = -40; x <= mapWidth + 40; x += 16) {
+      const wave = Math.sin(x / 70 + this.time * 2) * 6 + Math.sin(x / 23 - this.time * 3.1) * 2.5;
+      pts.push(x, level + wave);
+    }
+    pts.push(mapWidth + 40, bottom, -40, bottom);
+    g.poly(pts).fill(CUSTARD_COLOR);
+    // Darker depth band and the felt line on the surface.
+    g.rect(-40, level + 40, mapWidth + 80, bottom - level).fill({ color: CUSTARD_DARK, alpha: 0.35 });
+    const surface = pts.slice(0, pts.length - 4);
+    g.moveTo(surface[0], surface[1]);
+    for (let i = 2; i < surface.length; i += 2) g.lineTo(surface[i], surface[i + 1]);
+    g.stroke({ width: W + 1, color: INK });
+    // Shine and bubbles.
+    for (let i = 0; i < 18; i++) {
+      const bx = ((i * 211 + this.time * 30 * ((i % 3) + 1)) % (mapWidth + 80)) - 40;
+      const by = level + 14 + ((i * 37) % 60);
+      const r = 3 + (i % 4) * 1.5;
+      g.circle(bx, by, r).fill({ color: 0xfffbe0, alpha: 0.7 }).stroke({ width: 1.5, color: CUSTARD_DARK });
+    }
+  }
+}
+
+/** The cherry, with a stem and a leaf, bobbing a little; glows when someone holds it. */
+export class CherryView {
+  readonly graphics = new Graphics();
+  private time = 0;
+
+  update(cherry: CherryState | null, dt: number): void {
+    this.time += dt;
+    const g = this.graphics;
+    g.clear();
+    if (!cherry) return;
+    const bob = cherry.holder ? Math.sin(this.time * 6) * 3 : 0;
+    const x = cherry.x;
+    const y = cherry.y + bob;
+    if (cherry.holder) g.circle(x, y, 22 + Math.sin(this.time * 8) * 2).fill({ color: 0xffe14d, alpha: 0.35 });
+    g.moveTo(x + 2, y - 10).quadraticCurveTo(x + 6, y - 22, x + 12, y - 26).stroke({ width: 3, color: 0x4a7a2a });
+    g.ellipse(x + 15, y - 24, 8, 4).fill(0x7ccf5a).stroke({ width: 2, color: INK });
+    g.circle(x, y, 12).fill(0xe0123a).stroke({ width: W, color: INK });
+    g.ellipse(x - 4, y - 4, 4, 3).fill({ color: 0xffffff, alpha: 0.75 });
+  }
+}
+
+/** Draws one item in local coordinates (origin at its center). */
+export function paintItem(g: Graphics, kind: ItemKind, lit: boolean, time: number): void {
+  if (kind === 'candle') {
+    g.roundRect(-5, -14, 10, 26, 3).fill(0xffffff).stroke({ width: 2.5, color: INK });
+    for (let i = 0; i < 3; i++) g.rect(-5, -12 + i * 9, 10, 4).fill(0xff6f91);
+    g.moveTo(0, -14).lineTo(0, -19).stroke({ width: 2, color: INK });
+    if (lit) {
+      const f = 1 + Math.sin(time * 40) * 0.2;
+      g.ellipse(0, -24, 4 * f, 7 * f).fill(0xffb02e).stroke({ width: 1.5, color: INK });
+      g.ellipse(0, -22, 2, 3.5).fill(0xfff3a0);
+    }
+  } else if (kind === 'smarties') {
+    g.roundRect(-14, -6, 28, 12, 6).fill(0xfff6e4).stroke({ width: 2.5, color: INK });
+    const colors = [0xff3b6b, 0xffe14d, 0x4dc4ff, 0x9be15d, 0xb18cff];
+    colors.forEach((c, i) => g.circle(-10 + i * 5, 0, 3.4).fill(c).stroke({ width: 1, color: INK }));
+  } else {
+    g.roundRect(-3, 2, 6, 14, 2).fill(0x9c6b3a).stroke({ width: 2, color: INK });
+    for (const k of [-1, 0, 1]) g.ellipse(k * 3, -8, 5 - Math.abs(k), 11).stroke({ width: 2, color: 0x8899aa });
+    if (Math.sin(time * 25) > 0) {
+      g.moveTo(-10, -18).lineTo(-6, -12).lineTo(-11, -10).stroke({ width: 2, color: 0x4dc4ff });
+      g.moveTo(10, -16).lineTo(6, -11).lineTo(11, -8).stroke({ width: 2, color: 0x4dc4ff });
+    }
+  }
+}
+
+/** Items lying around or flying. */
+export class ItemsView {
+  readonly container = new Container();
+  private readonly views = new Map<number, Graphics>();
+  private time = 0;
+
+  update(items: Map<number, ItemState>, dt: number): void {
+    this.time += dt;
+    for (const [id, g] of this.views) {
+      if (items.has(id)) continue;
+      g.destroy();
+      this.views.delete(id);
+    }
+    for (const [id, item] of items) {
+      let g = this.views.get(id);
+      if (!g) {
+        g = new Graphics();
+        this.views.set(id, g);
+        this.container.addChild(g);
+      }
+      g.clear();
+      // A gentle sparkle so pickups catch the eye.
+      if (!item.lit) g.circle(0, 0, 18 + Math.sin(this.time * 5 + id) * 2).fill({ color: 0xffffff, alpha: 0.35 });
+      paintItem(g, item.kind, item.lit, this.time);
+      g.position.set(item.x, item.y);
+      g.rotation = item.angle;
+    }
+  }
+
+  clear(): void {
+    for (const g of this.views.values()) g.destroy();
+    this.views.clear();
+  }
+}
+
+interface Spoon {
+  g: Graphics;
+  x: number;
+  y: number;
+  age: number;
+}
+
+const SPOON_LIFE = 0.8;
+
+/** The spoon catapult flicking a player back onto the cake. */
+export class SpoonsView {
+  readonly container = new Container();
+  private spoons: Spoon[] = [];
+
+  spawn(x: number, y: number): void {
+    const g = new Graphics();
+    this.container.addChild(g);
+    this.spoons.push({ g, x, y, age: 0 });
+  }
+
+  update(dt: number): void {
+    this.spoons = this.spoons.filter((s) => {
+      s.age += dt;
+      if (s.age > SPOON_LIFE) {
+        s.g.destroy();
+        return false;
+      }
+      const t = s.age / SPOON_LIFE;
+      // Flick: the bowl swings up fast, then sinks back.
+      const angle = t < 0.25 ? -0.2 - (t / 0.25) * 1.4 : -1.6 + (t - 0.25) * 2;
+      const g = s.g;
+      g.clear();
+      g.roundRect(0, -5, 90, 10, 5).fill(0xc9d3dd).stroke({ width: W, color: INK });
+      g.ellipse(100, 0, 22, 15).fill(0xdfe7ee).stroke({ width: W, color: INK });
+      g.ellipse(96, -4, 8, 5).fill({ color: 0xffffff, alpha: 0.8 });
+      g.position.set(s.x - 100 * Math.cos(angle), s.y + 10);
+      g.rotation = angle;
+      g.alpha = t > 0.8 ? (1 - t) / 0.2 : 1;
+      return true;
+    });
+  }
+}

+ 35 - 2
src/renderer/i18n/fr.ts

@@ -14,8 +14,8 @@ export const fr = {
   rejectedCharacter: 'Ce personnage n’existe pas sur ce serveur.',
   loadError: (msg: string) => `Impossible de charger la partie : ${msg}`,
   help: {
-    generic: 'Q/D : courir · Espace/Z : sauter · Clic gauche : croquer le décor',
-    castor: 'Q/D : courir · Espace/Z : sauter · Clic gauche : coup de dents · Clic droit (maintenu) : manger le décor',
+    generic: 'Q/D : courir · Espace/Z : sauter · Clic gauche : croquer le décor · F : lancer la bougie',
+    castor: 'Q/D : courir · Espace/Z : sauter · Clic gauche : coup de dents · Clic droit (maintenu) : manger · F : lancer la bougie',
   } as Record<string, string>,
   fps: (n: number) => `${n} FPS`,
   browser: {
@@ -42,6 +42,39 @@ export const fr = {
     started: 'C’est parti !',
   },
   desync: 'Oups, ton gâteau ne ressemble plus à celui du serveur (désynchronisation).',
+  hud: {
+    round: (n: number, total: number) => `Manche ${n}/${total}`,
+    warmup: 'Échauffement',
+    rush: 'LA CRÈME MONTE !',
+    candleHint: 'F : lancer la bougie !',
+  },
+  items: { candle: 'Bougie !', smarties: 'Smarties !', whisk: 'Fouet électrique !', cherry: 'La cerise !' } as Record<string, string>,
+  fxWords: {
+    splash: ['PLOUF !', 'SPLOTCH !', 'GLOUB !'],
+    explosion: ['BOUM !', 'KABOUM !', 'BADABOUM !'],
+    whisk: ['BZZZT !', 'VRRRRR !'],
+    drop: ['OUPS !', 'AÏE !', 'NOOON !'],
+  },
+  results: {
+    roundTitle: (n: number, total: number) => `Fin de la manche ${n}/${total}`,
+    matchTitle: 'Le grand classement',
+    player: 'Gourmand',
+    round: 'Manche',
+    total: 'Total',
+    next: (s: number) => `Manche suivante dans ${s} s…`,
+    lobby: (s: number) => `Retour à l’échauffement dans ${s} s… (« Je suis prêt ! » pour rejouer)`,
+    trophies: 'Les trophées',
+    noTrophy: 'Personne n’a rien fait de remarquable. Bravo.',
+    winner: (name: string) => `${name} remporte le goûter !`,
+  },
+  trophies: {
+    glutton: { name: 'Le Glouton', desc: (v: number) => `${v.toLocaleString('fr-FR')} pixels de gâteau engloutis` },
+    kamikaze: { name: 'Le Kamikaze', desc: (v: number) => `${v} plongeon${v > 1 ? 's' : ''} dans la crème` },
+    developer: { name: 'Le Promoteur Immobilier', desc: (v: number) => `${v} pan${v > 1 ? 's' : ''} de gâteau effondré${v > 1 ? 's' : ''}` },
+    notMyFault: { name: 'Pas Sa Faute', desc: (v: number) => `${v} coup${v > 1 ? 's' : ''} et écrasement${v > 1 ? 's' : ''} encaissés` },
+    pyromaniac: { name: 'L’Artificier', desc: (v: number) => `${v} bougie${v > 1 ? 's' : ''} lancée${v > 1 ? 's' : ''}` },
+    cherryPicker: { name: 'Le Cueilleur', desc: (v: number) => `${v} s avec la cerise` },
+  } as Record<string, { name: string; desc: (v: number) => string }>,
   defaultName: 'Gourmand',
   characters: {
     generic: { name: 'Le Testeur', desc: 'Court, saute, croque. Rien de plus, rien de moins.' },

+ 1 - 0
src/renderer/input.ts

@@ -12,6 +12,7 @@ const KEYMAP: Record<string, number> = {
   KeyS: Button.Down,
   ArrowDown: Button.Down,
   KeyE: Button.Special,
+  KeyF: Button.Use,
 };
 
 /** Tracks keyboard/mouse state as a button bitmask plus the mouse position in screen pixels. */

+ 88 - 5
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 ChunkInfo, type ChunkState, type PlayerInfo, type PlayerState, type ServerMessage } from '../shared/protocol';
+import { hello, type ChunkInfo, type ChunkState, type FxEvent, 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';
@@ -13,6 +13,8 @@ import {
   CAMERA_FOLLOW_SPEED,
   CAMERA_VIEW_HEIGHT,
   CRUSH_SHAKE,
+  CUSTARD_COLOR,
+  EXPLOSION_SHAKE,
   FREEZE_FRAME_MS,
   HIT_SHAKE,
   INPUT_SEND_HZ,
@@ -25,9 +27,11 @@ import {
   SHAKE_DECAY,
   SHAKE_MAX,
   SHAKE_PER_PIXEL,
+  WHISK_SHAKE,
 } from './config';
 import { ChunkView } from './entities/ChunkView';
 import { PlayerView } from './entities/PlayerView';
+import { CherryView, CustardView, ItemsView, SpoonsView } from './entities/RoundViews';
 import { Particles } from './fx/Particles';
 import { Popups } from './fx/Popups';
 import { fr } from './i18n/fr';
@@ -36,6 +40,7 @@ import { Connection } from './net/connection';
 import { SnapshotBuffer } from './net/interpolation';
 import { TerrainView } from './terrain/TerrainView';
 import { showBrowser } from './ui/browser';
+import { ResultsScreen, RoundHud } from './ui/hud';
 import { LobbyPanel } from './ui/lobbyPanel';
 import { showMenu, type MenuChoice } from './ui/menu';
 
@@ -127,8 +132,26 @@ async function start(): Promise<void> {
   const popups = new Popups();
   const playersLayer = new Container();
   const chunksLayer = new Container();
+  const itemsView = new ItemsView();
+  const cherryView = new CherryView();
+  const custardView = new CustardView();
+  const spoonsView = new SpoonsView();
   // Map-independent layers always sit above the map ones (background, terrain).
-  worldRoot.addChild(chunksLayer, playersLayer, particles.graphics, popups.container);
+  // The custard covers players who sink into it; the spoon flicks out of it.
+  worldRoot.addChild(
+    chunksLayer,
+    itemsView.container,
+    playersLayer,
+    cherryView.graphics,
+    custardView.graphics,
+    spoonsView.container,
+    particles.graphics,
+    popups.container,
+  );
+  const hud = new RoundHud();
+  const results = new ResultsScreen();
+  let round = 0;
+  let rounds = 0;
 
   const materials = parseMaterials(await window.assets.materials());
 
@@ -243,6 +266,7 @@ async function start(): Promise<void> {
     const gen = ++generation;
     if (world) {
       world.background.destroy({ texture: true, textureSource: true });
+      itemsView.clear();
       world.view.container.destroy({ children: true, texture: true, textureSource: true });
     }
     world = null;
@@ -269,6 +293,41 @@ async function start(): Promise<void> {
       .catch((err: unknown) => setStatus(fr.loadError(err instanceof Error ? err.message : String(err))));
   };
 
+  /** One-shot cues from the server: splashes, spoons, explosions, pickups. */
+  const onFx = (ev: FxEvent): void => {
+    switch (ev.kind) {
+      case 'splash':
+        particles.burst(ev.x, ev.y, 16, CUSTARD_COLOR, 420);
+        popups.spawn(ev.x, ev.y - 30, fr.fxWords.splash, ev.id ? 36 : 26);
+        break;
+      case 'spoon':
+        spoonsView.spawn(ev.x, ev.y);
+        break;
+      case 'explosion':
+        particles.burst(ev.x, ev.y, 40, 0xffb02e, 650);
+        particles.burst(ev.x, ev.y, 20, 0xff6f91, 400);
+        particles.puff(ev.x, ev.y, 14, 0xfff3a0);
+        popups.spawn(ev.x, ev.y - ev.r, fr.fxWords.explosion, 48);
+        shake = Math.min(SHAKE_MAX, shake + EXPLOSION_SHAKE);
+        freezeUntil = performance.now() + FREEZE_FRAME_MS;
+        break;
+      case 'pickup':
+        popups.spawn(ev.x, ev.y - 20, [fr.items[ev.item] ?? ev.item], 28);
+        particles.burst(ev.x, ev.y, 10, 0xffe14d, 250);
+        break;
+      case 'cherryDrop':
+        popups.spawn(ev.x, ev.y - 40, fr.fxWords.drop, 32);
+        break;
+      case 'whisk':
+        shake = Math.min(SHAKE_MAX, shake + WHISK_SHAKE);
+        for (const s of states.values()) {
+          if (s.id !== ev.id) particles.burst(s.x, s.y, 8, 0x4dc4ff, 300);
+          else popups.spawn(s.x, s.y - s.size - 20, fr.fxWords.whisk, 40);
+        }
+        break;
+    }
+  };
+
   const lobbyPanel = new LobbyPanel(
     () => localId,
     (ready) => conn.send({ t: 'ready', ready }),
@@ -278,17 +337,32 @@ async function start(): Promise<void> {
     switch (msg.t) {
       case 'welcome':
         localId = msg.playerId;
+        round = msg.round;
+        rounds = msg.rounds;
         infos = new Map(msg.players.map((p) => [p.id, p]));
         resetWorld(msg.mapId, msg.events, msg.chunks);
         break;
       case 'start':
+        round = msg.round;
+        rounds = msg.rounds;
         infos = new Map(msg.players.map((p) => [p.id, p]));
+        results.hide();
         resetWorld(msg.mapId, [], []);
-        setStatus(fr.lobby.started);
+        if (round === 0) break; // back to the warm-up: no fanfare
+        setStatus(fr.hud.round(round, rounds));
         setTimeout(() => {
-          if (statusEl.textContent === fr.lobby.started) setStatus('');
+          if (statusEl.textContent === fr.hud.round(round, rounds)) setStatus('');
         }, 1500);
         break;
+      case 'roundEnd':
+        results.showRound(msg, localId);
+        break;
+      case 'matchEnd':
+        results.showMatch(msg, localId);
+        break;
+      case 'fx':
+        onFx(msg.ev);
+        break;
       case 'lobby':
         lobbyPanel.update(msg.phase, msg.players, msg.countdown);
         break;
@@ -326,7 +400,7 @@ async function start(): Promise<void> {
         freezeUntil = performance.now() + FREEZE_FRAME_MS * 2;
         break;
       case 'snapshot':
-        snapshots.push(performance.now(), msg.players, msg.chunks);
+        snapshots.push(performance.now(), msg.players, msg.chunks, msg.items, msg.round);
         break;
     }
   };
@@ -382,6 +456,11 @@ async function start(): Promise<void> {
       const s = sample.chunks.get(id);
       if (s) view.update(s);
     }
+    itemsView.update(sample.items, dt);
+    cherryView.update(sample.round?.cherry ?? null, dt);
+    if (sample.round) custardView.update(sample.round.custardY, world.terrain.width, world.terrain.height, dt, sample.round.finalRush);
+    spoonsView.update(dt);
+    hud.update(round, rounds, sample.round, states, infos, localId);
     const [aimX, aimY] = screenToWorld(input.mouseX, input.mouseY);
     for (const [id, state] of states) {
       const info = infos.get(id);
@@ -394,6 +473,10 @@ async function start(): Promise<void> {
       }
       const ev = view.update(state, id === localId ? aimX : state.x + state.facing * 100, id === localId ? aimY : state.y, dt);
       if (ev.landed && ev.landingSpeed > LANDING_DUST_MIN_VY) particles.puff(state.x, state.y + state.size, 8);
+      // Smarties: a sugary trail.
+      if (state.anim & Anim.Boosted && Math.random() < 0.5) {
+        particles.burst(state.x, state.y + state.size * 0.5, 1, [0xff3b6b, 0xffe14d, 0x4dc4ff, 0x9be15d][Math.floor(Math.random() * 4)], 60);
+      }
       if (ev.stunnedNow) {
         popups.spawn(state.x, state.y - state.size, fr.onomatopoeia.hit, 40);
         particles.burst(state.x, state.y, 10, 0xffe14d, 300);

+ 33 - 7
src/renderer/net/interpolation.ts

@@ -1,14 +1,18 @@
-import type { ChunkState, PlayerState } from '../../shared/protocol';
+import type { ChunkState, ItemState, PlayerState, RoundState } from '../../shared/protocol';
 
 interface Timed {
   time: number;
   players: Map<number, PlayerState>;
   chunks: Map<number, ChunkState>;
+  items: Map<number, ItemState>;
+  round: RoundState | null;
 }
 
 export interface Sample {
   players: Map<number, PlayerState>;
   chunks: Map<number, ChunkState>;
+  items: Map<number, ItemState>;
+  round: RoundState | null;
 }
 
 const MAX_BUFFER = 32;
@@ -21,18 +25,26 @@ function lerpAngle(a: number, b: number, t: number): number {
   return a + d * t;
 }
 
+const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
+
 /** Buffers snapshots and renders entities slightly in the past, lerping between the two around the render time. */
 export class SnapshotBuffer {
   private buffer: Timed[] = [];
 
-  push(time: number, players: PlayerState[], chunks: ChunkState[]): void {
-    this.buffer.push({ time, players: new Map(players.map((p) => [p.id, p])), chunks: new Map(chunks.map((c) => [c.id, c])) });
+  push(time: number, players: PlayerState[], chunks: ChunkState[], items: ItemState[] = [], round: RoundState | null = null): void {
+    this.buffer.push({
+      time,
+      players: new Map(players.map((p) => [p.id, p])),
+      chunks: new Map(chunks.map((c) => [c.id, c])),
+      items: new Map(items.map((i) => [i.id, i])),
+      round,
+    });
     if (this.buffer.length > MAX_BUFFER) this.buffer.shift();
   }
 
   sample(renderTime: number): Sample {
     const b = this.buffer;
-    if (b.length === 0) return { players: new Map(), chunks: new Map() };
+    if (b.length === 0) return { players: new Map(), chunks: new Map(), items: new Map(), round: null };
     if (renderTime <= b[0].time) return b[0];
     for (let i = b.length - 1; i > 0; i--) {
       const a = b[i - 1];
@@ -43,14 +55,28 @@ export class SnapshotBuffer {
       const players = new Map<number, PlayerState>();
       for (const [id, pc] of c.players) {
         const pa = a.players.get(id);
-        players.set(id, pa ? { ...pc, x: pa.x + (pc.x - pa.x) * t, y: pa.y + (pc.y - pa.y) * t } : pc);
+        players.set(id, pa ? { ...pc, x: lerp(pa.x, pc.x, t), y: lerp(pa.y, pc.y, t) } : pc);
       }
       const chunks = new Map<number, ChunkState>();
       for (const [id, cc] of c.chunks) {
         const ca = a.chunks.get(id);
-        chunks.set(id, ca ? { id, x: ca.x + (cc.x - ca.x) * t, y: ca.y + (cc.y - ca.y) * t, angle: lerpAngle(ca.angle, cc.angle, t) } : cc);
+        chunks.set(id, ca ? { id, x: lerp(ca.x, cc.x, t), y: lerp(ca.y, cc.y, t), angle: lerpAngle(ca.angle, cc.angle, t) } : cc);
+      }
+      const items = new Map<number, ItemState>();
+      for (const [id, ic] of c.items) {
+        const ia = a.items.get(id);
+        items.set(id, ia ? { ...ic, x: lerp(ia.x, ic.x, t), y: lerp(ia.y, ic.y, t), angle: lerpAngle(ia.angle, ic.angle, t) } : ic);
+      }
+      let round = c.round;
+      if (round && a.round) {
+        const ch = round.cherry && a.round.cherry && round.cherry.holder === a.round.cherry.holder ? round.cherry : null;
+        round = {
+          ...round,
+          custardY: lerp(a.round.custardY, round.custardY, t),
+          cherry: ch && a.round.cherry ? { ...ch, x: lerp(a.round.cherry.x, ch.x, t), y: lerp(a.round.cherry.y, ch.y, t) } : round.cherry,
+        };
       }
-      return { players, chunks };
+      return { players, chunks, items, round };
     }
     return b[b.length - 1];
   }

+ 184 - 0
src/renderer/style.css

@@ -391,3 +391,187 @@ input.invalid {
   margin: 4px 0 10px;
   font-weight: bold;
 }
+
+/* ---------- Round HUD ---------- */
+
+#round-hud {
+  position: fixed;
+  top: 8px;
+  left: 50%;
+  transform: translateX(-50%) rotate(-1deg);
+  background: var(--paper);
+  border: 3px solid var(--ink);
+  border-radius: 16px 11px 18px 10px;
+  box-shadow: 4px 4px 0 rgba(59, 36, 20, 0.4);
+  padding: 2px 18px 4px;
+  text-align: center;
+  pointer-events: none;
+  z-index: 4;
+}
+
+#round-hud[hidden] {
+  display: none;
+}
+
+#round-hud .label {
+  font-size: 13px;
+}
+
+#round-hud .timer {
+  font-size: 30px;
+  font-weight: bold;
+  line-height: 1;
+}
+
+#round-hud.rush .timer {
+  color: #d93a5b;
+  animation: wobble 0.5s infinite alternate;
+}
+
+#round-hud .rush-label {
+  font-size: 12px;
+  font-weight: bold;
+  color: #d93a5b;
+}
+
+@keyframes wobble {
+  from {
+    transform: scale(1) rotate(-2deg);
+  }
+  to {
+    transform: scale(1.12) rotate(2deg);
+  }
+}
+
+#scoreboard {
+  position: fixed;
+  top: 10px;
+  left: 12px;
+  list-style: none;
+  margin: 0;
+  padding: 6px 10px;
+  background: rgba(255, 246, 228, 0.88);
+  border: 3px solid var(--ink);
+  border-radius: 14px 10px 15px 9px;
+  min-width: 150px;
+  pointer-events: none;
+  z-index: 4;
+}
+
+#scoreboard[hidden] {
+  display: none;
+}
+
+#scoreboard li {
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+  font-size: 15px;
+}
+
+#scoreboard li.me {
+  font-weight: bold;
+}
+
+#scoreboard li.holder::before {
+  content: '🍒 ';
+}
+
+#scoreboard li.dead {
+  opacity: 0.45;
+}
+
+#hint {
+  position: fixed;
+  bottom: 60px;
+  left: 50%;
+  transform: translateX(-50%);
+  font-weight: bold;
+  font-size: 18px;
+  text-shadow:
+    2px 2px 0 #fff,
+    -2px -2px 0 #fff;
+  pointer-events: none;
+}
+
+/* ---------- Results ---------- */
+
+#results {
+  position: fixed;
+  inset: 0;
+  display: grid;
+  place-items: center;
+  background: rgba(59, 36, 20, 0.35);
+  z-index: 8;
+}
+
+#results[hidden] {
+  display: none;
+}
+
+#results table {
+  margin: 0 auto 10px;
+  border-collapse: collapse;
+  font-size: 18px;
+  min-width: 340px;
+}
+
+#results th,
+#results td {
+  padding: 4px 12px;
+  border-bottom: 2px dashed rgba(59, 36, 20, 0.25);
+}
+
+#results td.num {
+  text-align: right;
+  font-weight: bold;
+}
+
+#results tr.me td {
+  color: var(--pink-dark);
+}
+
+#results tr:first-child td:first-child::before {
+  content: '👑 ';
+}
+
+#results .winner {
+  font-size: 24px;
+  font-weight: bold;
+  color: var(--pink);
+  -webkit-text-stroke: 1px var(--ink);
+  margin: 0 0 10px;
+}
+
+#results .trophies {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
+  gap: 10px;
+  margin: 8px 0 12px;
+}
+
+#results .trophy {
+  background: #fff;
+  border: 3px solid var(--ink);
+  border-radius: 14px 10px 15px 9px;
+  padding: 6px 8px;
+  transform: rotate(-1deg);
+}
+
+#results .trophy:nth-child(even) {
+  transform: rotate(1.2deg);
+}
+
+#results .trophy strong {
+  display: block;
+  color: var(--pink-dark);
+}
+
+#results .trophy span {
+  font-size: 13px;
+}
+
+#results .footer {
+  font-size: 14px;
+  opacity: 0.8;
+}

+ 144 - 0
src/renderer/ui/hud.ts

@@ -0,0 +1,144 @@
+import { Anim } from '../../shared/characters';
+import type { MatchEndMessage, PlayerInfo, PlayerState, RoundEndMessage, RoundState } from '../../shared/protocol';
+import { fr } from '../i18n/fr';
+
+const el = <K extends keyof HTMLElementTagNameMap>(tag: K, className = '', text = ''): HTMLElementTagNameMap[K] => {
+  const e = document.createElement(tag);
+  if (className) e.className = className;
+  if (text) e.textContent = text;
+  return e;
+};
+
+/** Round banner (round, timer, final rush), scoreboard and contextual hint. */
+export class RoundHud {
+  private readonly banner = el('div');
+  private readonly label = el('div', 'label');
+  private readonly timer = el('div', 'timer');
+  private readonly rush = el('div', 'rush-label', fr.hud.rush);
+  private readonly board = el('ul');
+  private readonly hint = el('div');
+  private lastKey = '';
+
+  constructor() {
+    this.banner.id = 'round-hud';
+    this.board.id = 'scoreboard';
+    this.hint.id = 'hint';
+    this.banner.append(this.label, this.timer, this.rush);
+    document.body.append(this.banner, this.board, this.hint);
+    this.banner.hidden = true;
+    this.board.hidden = true;
+  }
+
+  update(round: number, rounds: number, state: RoundState | null, players: Map<number, PlayerState>, infos: Map<number, PlayerInfo>, localId: number): void {
+    const playing = round > 0 && state?.timeLeft != null;
+    this.banner.hidden = !playing;
+    this.board.hidden = !playing;
+    const me = players.get(localId);
+    const alive = me !== undefined && (me.anim & Anim.Dead) === 0;
+    this.hint.textContent = alive && me.item === 'candle' ? fr.hud.candleHint : '';
+    if (!playing || !state) return;
+
+    const t = Math.ceil(state.timeLeft ?? 0);
+    this.label.textContent = fr.hud.round(round, rounds);
+    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;
+
+    // Rebuild the scoreboard only when something visible changed.
+    const rows = [...players.values()]
+      .map((p) => ({ p, name: infos.get(p.id)?.name ?? '?' }))
+      .sort((a, b) => b.p.score - a.p.score || a.p.id - b.p.id);
+    const holder = state.cherry?.holder ?? 0;
+    const key = rows.map(({ p, name }) => `${p.id}:${name}:${p.score}:${p.anim & Anim.Dead}`).join('|') + `#${holder}`;
+    if (key === this.lastKey) return;
+    this.lastKey = key;
+    this.board.replaceChildren(
+      ...rows.map(({ p, name }) => {
+        const li = el('li');
+        li.append(el('span', '', name), el('span', '', String(p.score)));
+        li.classList.toggle('me', p.id === localId);
+        li.classList.toggle('holder', p.id === holder);
+        li.classList.toggle('dead', (p.anim & Anim.Dead) !== 0);
+        return li;
+      }),
+    );
+  }
+}
+
+/** Round results and final results (with trophies) overlay. */
+export class ResultsScreen {
+  private readonly root = el('div');
+  private timer: ReturnType<typeof setInterval> | null = null;
+
+  constructor() {
+    this.root.id = 'results';
+    this.root.hidden = true;
+    document.body.appendChild(this.root);
+  }
+
+  hide(): void {
+    this.root.hidden = true;
+    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);
+  }
+
+  showMatch(msg: MatchEndMessage, localId: number): void {
+    this.show(fr.results.matchTitle, msg.results, localId, msg, msg.lobbyIn, fr.results.lobby);
+  }
+
+  private show(
+    title: string,
+    results: RoundEndMessage['results'],
+    localId: number,
+    match: MatchEndMessage | null,
+    seconds: number,
+    footer: (s: number) => string,
+  ): void {
+    const card = el('div', 'menu-card');
+    card.appendChild(el('h1', 'small', title));
+    if (match && results[0]) card.appendChild(el('p', 'winner', fr.results.winner(results[0].name)));
+
+    const table = el('table');
+    const head = el('tr');
+    for (const h of [fr.results.player, fr.results.round, fr.results.total]) head.appendChild(el('th', '', h));
+    table.appendChild(head);
+    for (const r of results) {
+      const tr = el('tr', r.id === localId ? 'me' : '');
+      tr.append(el('td', '', r.name), el('td', 'num', String(r.roundScore)), el('td', 'num', String(r.total)));
+      table.appendChild(tr);
+    }
+    card.appendChild(table);
+
+    if (match) {
+      card.appendChild(el('h2', '', fr.results.trophies));
+      const grid = el('div', 'trophies');
+      const names = new Map(results.map((r) => [r.id, r.name]));
+      for (const t of match.trophies) {
+        const text = fr.trophies[t.id];
+        if (!text) continue;
+        const box = el('div', 'trophy');
+        box.append(el('strong', '', `🏆 ${text.name}`), el('div', '', names.get(t.playerId) ?? '?'), el('span', '', text.desc(t.value)));
+        grid.appendChild(box);
+      }
+      if (grid.childElementCount === 0) grid.appendChild(el('p', '', fr.results.noTrophy));
+      card.appendChild(grid);
+    }
+
+    const foot = el('p', 'footer');
+    card.appendChild(foot);
+    this.root.replaceChildren(card);
+    this.root.hidden = false;
+
+    let left = Math.ceil(seconds);
+    foot.textContent = footer(left);
+    if (this.timer) clearInterval(this.timer);
+    this.timer = setInterval(() => {
+      left = Math.max(0, left - 1);
+      foot.textContent = footer(left);
+    }, 1000);
+  }
+}

+ 475 - 48
src/server/game.ts

@@ -2,6 +2,17 @@ import { Circle, World, type Body, type Contact, type ContactImpulse, type Fixtu
 import { Anim, createCharacter, type Character, type CharacterContext, type CharacterStats } from '../shared/characters';
 import {
   AIR_CONTROL,
+  CANDLE_ARM_S,
+  CANDLE_BLAST_POWER,
+  CANDLE_BLAST_RADIUS,
+  CANDLE_FUSE_S,
+  CANDLE_KNOCKBACK,
+  CANDLE_STUN_S,
+  CANDLE_THROW_SPEED,
+  CHERRY_DROP_SPEED,
+  CHERRY_POINTS_PER_S,
+  CHERRY_RADIUS,
+  CHERRY_REGRAB_DELAY,
   CHUNK_GLUE_PX,
   CHUNK_MERGE_RETRY,
   CHUNK_REST_ANGULAR,
@@ -10,20 +21,33 @@ import {
   COYOTE_TIME,
   CRUSH_IMPULSE,
   CRUSH_MIN_SPEED,
+  CUSTARD_SINK_PX,
+  FINAL_RUSH_S,
   GRAVITY_PX,
   GROUND_NORMAL_Y,
+  ITEM_LIFETIME_S,
+  ITEM_RADIUS,
   JUMP_BUFFER_TIME,
   JUMP_CUT,
   MIN_CHUNK_PIXELS,
   OUT_OF_MAP_MARGIN,
   PIXELS_PER_METER,
   RESIZE_THRESHOLD_PX,
+  RESPAWN_DELAY_S,
+  SMARTIES_DURATION_S,
+  SMARTIES_SPEED_MULT,
+  SPOON_LAUNCH_SPEED,
   TICK_DT,
+  WHISK_CRACK_POWER,
+  WHISK_CRACK_RADIUS,
+  WHISK_CRACKS,
+  WHISK_JOLT,
+  WHISK_STUN_S,
 } from '../shared/constants';
 import { Button, EMPTY_INPUT, isDown, type InputState } from '../shared/input';
 import type { MapDefinition } from '../shared/maps/loader';
 import type { MaterialRegistry } from '../shared/materials';
-import type { ChunkInfo, ChunkState, PlayerInfo, PlayerState } from '../shared/protocol';
+import type { ChunkInfo, ChunkState, FxEvent, ItemKind, ItemState, PlayerInfo, PlayerState, RoundState } from '../shared/protocol';
 import { Rng } from '../shared/rng';
 import type { TerrainBitmap } from '../shared/terrain/bitmap';
 import { DetachFinder, type DetachedComponent } from '../shared/terrain/detach';
@@ -32,8 +56,19 @@ import type { TerrainOp } from '../shared/terrain/ops';
 import { rleEncode } from '../shared/terrain/rle';
 import { FallingChunk, type FixtureTag } from './physics/fallingChunk';
 import { TerrainColliders } from './physics/terrainColliders';
+import { MatchStats } from './stats';
 
 const PPM = PIXELS_PER_METER;
+const ITEM_KINDS: readonly ItemKind[] = ['candle', 'smarties', 'whisk'];
+const JELLY = 4;
+/** Height above a player's head where a held cherry sits (px). */
+const CHERRY_HOLD_OFFSET = 16;
+/** Columns scanned for a free spoon launch (px between candidates). */
+const SPOON_COLUMN_STEP = 32;
+/** Cap on the spoon's sideways throw (px/s). */
+const SPOON_MAX_VX = 500;
+/** Free space required on each side of the body along the launch column (px). */
+const SPOON_CLEARANCE = 8;
 
 interface ServerPlayer {
   info: PlayerInfo;
@@ -51,13 +86,41 @@ interface ServerPlayer {
   attackCooldown: number;
   specialCooldown: number;
   stun: number;
+  /** Seconds before the spoon sends the player back; 0 = alive. */
+  dead: number;
+  /** Smarties boost time left. */
+  boost: number;
+  /** Spoon flight time left: horizontal momentum is kept unless the player steers. */
+  flight: number;
+  item: ItemKind | null;
+  /** Seconds holding the cherry this round. */
+  score: number;
 }
 
 interface PendingOp {
   op: TerrainOp;
+  /** Player who caused it (0 = the game itself), for stats. */
+  owner: number;
   onApplied?: (removed: number) => void;
 }
 
+interface Item {
+  id: number;
+  kind: ItemKind;
+  body: Body;
+  age: number;
+  /** Set once a candle is thrown. */
+  thrown: { owner: number; fuse: number } | null;
+}
+
+interface Cherry {
+  body: Body;
+  holder: number;
+  /** Player who cannot grab it back yet, and for how long. */
+  blocked: number;
+  blockedFor: number;
+}
+
 export interface Crush {
   id: number;
   x: number;
@@ -67,9 +130,15 @@ export interface Crush {
 /** What happened during one tick, for the transport to broadcast. */
 export interface StepResult {
   terrain: SequencedEvent[];
-  /** Chunks that left without merging (fell off the map). */
+  /** Chunks that left without merging (fell off the map or into the custard). */
   removedChunks: number[];
   crushed: Crush[];
+  fx: FxEvent[];
+}
+
+export interface RoundRules {
+  /** Round length (s). */
+  duration: number;
 }
 
 export interface GameOptions {
@@ -78,20 +147,26 @@ export interface GameOptions {
   materials: MaterialRegistry;
   seed: number;
   loadCharacter: (id: string) => CharacterStats;
+  /** Null during the warm-up: no timer, no cherry, no items, the custard stays put. */
+  rules?: RoundRules | null;
+  /** Match-wide stats sink (trophies). */
+  stats?: MatchStats;
 }
 
-/** Authoritative simulation. Plain Node, no networking: see net.ts for the transport. */
+/** Authoritative simulation of one round. Plain Node, no networking: see net.ts for the transport. */
 export class Game {
   readonly map: MapDefinition;
   readonly terrain: TerrainBitmap;
   readonly seed: number;
   readonly rng: Rng;
+  readonly stats: MatchStats;
   tick = 0;
 
   private readonly world: World;
   private readonly colliders: TerrainColliders;
   private readonly materials: MaterialRegistry;
   private readonly loadCharacter: (id: string) => CharacterStats;
+  private readonly rules: RoundRules | null;
   private readonly players = new Map<number, ServerPlayer>();
   private readonly eventLog: SequencedEvent[] = [];
   private pendingOps: PendingOp[] = [];
@@ -103,6 +178,15 @@ export class Game {
   private nextPlayerId = 1;
   private nextSpawn = 0;
 
+  private custardY: number;
+  private timeLeft: number;
+  private cherry: Cherry | null = null;
+  private readonly items = new Map<number, Item>();
+  private nextItemId = 1;
+  private itemTimer = 0;
+  /** FX produced outside step() bookkeeping (hits, pickups), flushed into the step result. */
+  private fx: FxEvent[] = [];
+
   constructor(opts: GameOptions) {
     this.map = opts.map;
     this.terrain = opts.terrain;
@@ -110,17 +194,35 @@ export class Game {
     this.seed = opts.seed;
     this.rng = new Rng(opts.seed);
     this.loadCharacter = opts.loadCharacter;
+    this.rules = opts.rules ?? null;
+    this.stats = opts.stats ?? new MatchStats();
     this.world = new World({ gravity: { x: 0, y: GRAVITY_PX / PPM } });
     this.colliders = new TerrainColliders(this.world, this.terrain);
     this.colliders.rebuildDirty();
     this.finder = new DetachFinder(this.terrain);
     this.world.on('post-solve', (contact, impulse) => this.onPostSolve(contact, impulse));
+
+    this.custardY = this.map.custard.startY;
+    this.timeLeft = this.rules?.duration ?? Infinity;
+    if (this.rules) {
+      this.spawnCherry();
+      this.itemTimer = this.nextItemDelay();
+    }
   }
 
   get events(): readonly SequencedEvent[] {
     return this.eventLog;
   }
 
+  /** True once the round timer has run out. */
+  get roundOver(): boolean {
+    return this.rules !== null && this.timeLeft <= 0;
+  }
+
+  get custardLevel(): number {
+    return this.custardY;
+  }
+
   chunkInfos(): ChunkInfo[] {
     return [...this.chunks.values()].map((c) => c.info());
   }
@@ -133,7 +235,12 @@ export class Game {
     return [...this.players.values()].map((p) => p.info);
   }
 
-  /** Adds a player. The transport passes its own `id` so players keep it across matches. */
+  /** Whole points scored this round, by player id. */
+  scores(): Map<number, number> {
+    return new Map([...this.players.values()].map((p) => [p.info.id, Math.floor(p.score)]));
+  }
+
+  /** Adds a player. The transport passes its own `id` so players keep it across rounds. */
   addPlayer(name: string, characterId: string, id = this.nextPlayerId++): PlayerInfo {
     const stats = this.loadCharacter(characterId);
     this.nextPlayerId = Math.max(this.nextPlayerId, id + 1);
@@ -154,15 +261,21 @@ export class Game {
       attackCooldown: 0,
       specialCooldown: 0,
       stun: 0,
+      dead: 0,
+      boost: 0,
+      flight: 0,
+      item: null,
+      score: 0,
     };
     this.players.set(id, player);
-    this.respawn(player);
+    this.placeAtSpawn(player);
     return info;
   }
 
   removePlayer(id: number): void {
     const p = this.players.get(id);
     if (!p) return;
+    if (this.cherry?.holder === id) this.dropCherry(p);
     this.world.destroyBody(p.body);
     this.players.delete(id);
   }
@@ -182,25 +295,33 @@ export class Game {
   }
 
   /** Queues an op, applied later in this step (in queue order). */
-  queueOp(op: TerrainOp, onApplied?: (removed: number) => void): void {
-    this.pendingOps.push({ op, onApplied });
+  queueOp(op: TerrainOp, onApplied?: (removed: number) => void, owner = 0): void {
+    this.pendingOps.push({ op, owner, onApplied });
+  }
+
+  /** Spawns an item now (tests and debug). */
+  spawnItem(kind: ItemKind, x: number, y: number): number {
+    const id = this.nextItemId++;
+    const body = this.world.createBody({ type: 'dynamic', position: { x: x / PPM, y: y / PPM }, bullet: true, userData: { item: id } });
+    body.createFixture(new Circle(ITEM_RADIUS / PPM), { density: 0.6, friction: 0.6, restitution: 0.25, userData: { item: id } satisfies FixtureTag });
+    this.items.set(id, { id, kind, body, age: 0, thrown: null });
+    return id;
   }
 
   /** Advances one fixed tick. Terrain events in the result must be broadcast as-is, in order. */
   step(): StepResult {
-    const result: StepResult = { terrain: [], removedChunks: [], crushed: [] };
+    const result: StepResult = { terrain: [], removedChunks: [], crushed: [], fx: [] };
     for (const p of this.players.values()) this.updatePlayer(p);
 
     const pending = this.pendingOps;
     this.pendingOps = [];
-    for (const { op, onApplied } of pending) {
+    for (const { op, owner, onApplied } of pending) {
       const { changed, removedByMaterial } = this.emit({ kind: 'op', op }, result);
-      if (onApplied) {
-        let removed = 0;
-        for (const n of removedByMaterial.values()) removed += n;
-        onApplied(removed);
-      }
-      if (changed) for (const component of this.finder.find(changed)) this.detach(component, result);
+      let removed = 0;
+      for (const n of removedByMaterial.values()) removed += n;
+      this.stats.add(owner, 'eaten', removed);
+      onApplied?.(removed);
+      if (changed) for (const component of this.finder.find(changed)) this.detach(component, owner, result);
     }
     this.syncTerrain(result);
 
@@ -208,23 +329,74 @@ export class Game {
     this.world.step(TICK_DT, 8, 3);
     for (const id of this.crushedThisStep) {
       const p = this.players.get(id);
-      if (!p) continue;
+      if (!p || p.dead > 0) continue;
       const pos = p.body.getPosition();
       result.crushed.push({ id, x: pos.x * PPM, y: pos.y * PPM });
-      this.respawn(p);
+      this.kill(p, 'crushed');
     }
     for (const p of this.players.values()) this.postStep(p);
+    this.updateRound();
+    this.updateCherry();
+    this.updateItems();
     this.updateChunks(result);
     this.syncTerrain(result);
+    result.fx = this.fx;
+    this.fx = [];
     this.tick++;
     return result;
   }
 
+  snapshot(): PlayerState[] {
+    return [...this.players.values()].map((p) => {
+      const pos = p.body.getPosition();
+      const vel = p.body.getLinearVelocity();
+      return {
+        id: p.info.id,
+        x: pos.x * PPM,
+        y: pos.y * PPM,
+        vx: vel.x * PPM,
+        vy: vel.y * PPM,
+        facing: p.facing,
+        grounded: p.grounded,
+        size: p.character.size,
+        anim:
+          p.character.anim() | (p.stun > 0 ? Anim.Stunned : 0) | (p.dead > 0 ? Anim.Dead : 0) | (p.boost > 0 ? Anim.Boosted : 0),
+        score: Math.floor(p.score),
+        item: p.item,
+      };
+    });
+  }
+
+  itemStates(): ItemState[] {
+    return [...this.items.values()].map((it) => {
+      const pos = it.body.getPosition();
+      return { id: it.id, kind: it.kind, x: pos.x * PPM, y: pos.y * PPM, angle: it.body.getAngle(), lit: it.thrown !== null };
+    });
+  }
+
+  roundState(): RoundState {
+    let cherry: RoundState['cherry'] = null;
+    if (this.cherry) {
+      const pos = this.cherry.body.getPosition();
+      cherry = { x: pos.x * PPM, y: pos.y * PPM, holder: this.cherry.holder };
+    }
+    return {
+      custardY: this.custardY,
+      timeLeft: this.rules ? Math.max(0, this.timeLeft) : null,
+      finalRush: this.rules !== null && this.timeLeft <= FINAL_RUSH_S,
+      cherry,
+    };
+  }
+
+  // ---------------------------------------------------------------- terrain
+
   /** Rebuilds colliders after terrain changes and wakes every body that may have lost its support. */
   private syncTerrain(result: StepResult): void {
     if (this.colliders.rebuildDirty() === 0 && result.terrain.length === 0) return;
     for (const p of this.players.values()) p.body.setAwake(true);
     for (const c of this.chunks.values()) c.body.setAwake(true);
+    for (const it of this.items.values()) it.body.setAwake(true);
+    this.cherry?.body.setAwake(true);
   }
 
   /** Applies a terrain event and logs it if it changed anything. */
@@ -241,20 +413,24 @@ export class Game {
   }
 
   /** Removes an unanchored component from the bitmap; big ones become falling chunks, small ones crumble. */
-  private detach(c: DetachedComponent, result: StepResult): void {
+  private detach(c: DetachedComponent, owner: number, result: StepResult): void {
     const { x0, y0, x1, y1 } = c.bounds;
     const w = x1 - x0;
     const h = y1 - y0;
     const debris = c.pixelCount < MIN_CHUNK_PIXELS;
     const id = this.nextChunkId++;
     this.emit({ kind: 'detach', id, x: x0, y: y0, w, h, mask: rleEncode(c.mask), debris }, result);
-    if (!debris) this.chunks.set(id, new FallingChunk(this.world, id, c.mask, w, h, x0, y0, this.materials));
+    if (debris) return;
+    this.chunks.set(id, new FallingChunk(this.world, id, c.mask, w, h, x0, y0, this.materials));
+    this.stats.add(owner, 'detached');
   }
 
   private updateChunks(result: StepResult): void {
     for (const chunk of [...this.chunks.values()]) {
-      const pos = chunk.body.getPosition();
-      if (pos.y * PPM > this.terrain.height + OUT_OF_MAP_MARGIN) {
+      const center = chunk.body.getWorldCenter();
+      if (center.y * PPM > Math.min(this.custardY + CUSTARD_SINK_PX * 4, this.terrain.height + OUT_OF_MAP_MARGIN)) {
+        // Swallowed by the custard (readme §5).
+        if (center.y * PPM <= this.terrain.height + OUT_OF_MAP_MARGIN) this.fx.push({ kind: 'splash', id: 0, x: center.x * PPM, y: this.custardY });
         this.removeChunk(chunk);
         result.removedChunks.push(chunk.id);
         continue;
@@ -323,23 +499,7 @@ export class Game {
     if (total > CRUSH_IMPULSE) this.crushedThisStep.add(playerId);
   }
 
-  snapshot(): PlayerState[] {
-    return [...this.players.values()].map((p) => {
-      const pos = p.body.getPosition();
-      const vel = p.body.getLinearVelocity();
-      return {
-        id: p.info.id,
-        x: pos.x * PPM,
-        y: pos.y * PPM,
-        vx: vel.x * PPM,
-        vy: vel.y * PPM,
-        facing: p.facing,
-        grounded: p.grounded,
-        size: p.character.size,
-        anim: p.character.anim() | (p.stun > 0 ? Anim.Stunned : 0),
-      };
-    });
-  }
+  // ---------------------------------------------------------------- players
 
   private createBodyFixture(body: Body, radius: number, mass: number, id: number): Fixture {
     const tag: FixtureTag = { player: id };
@@ -354,8 +514,15 @@ export class Game {
   }
 
   private updatePlayer(p: ServerPlayer): void {
+    if (p.dead > 0) {
+      p.dead -= TICK_DT;
+      if (p.dead <= 0) this.spoonLaunch(p);
+      return;
+    }
     const { stats } = p.character;
     p.stun = Math.max(0, p.stun - TICK_DT);
+    p.boost = Math.max(0, p.boost - TICK_DT);
+    p.flight = Math.max(0, p.flight - TICK_DT);
     // Stunned players keep their momentum but ignore input.
     const input = p.stun > 0 ? { ...p.input, buttons: 0 } : p.input;
     const pressed = input.buttons & ~p.prevButtons;
@@ -366,12 +533,13 @@ export class Game {
     let vx = vel.x * PPM;
     let vy = vel.y * PPM;
     const mobility = p.character.mobility();
+    const speedMult = p.boost > 0 ? SMARTIES_SPEED_MULT : 1;
 
     if (p.stun === 0) {
       const dir = (isDown(input, Button.Right) ? 1 : 0) - (isDown(input, Button.Left) ? 1 : 0);
-      const accel = stats.acceleration * (p.grounded ? 1 : AIR_CONTROL) * TICK_DT;
-      const target = dir * stats.speed * mobility;
-      vx = vx < target ? Math.min(target, vx + accel) : Math.max(target, vx - accel);
+      const accel = stats.acceleration * speedMult * (p.grounded ? 1 : AIR_CONTROL) * TICK_DT;
+      const target = dir * stats.speed * mobility * speedMult;
+      if (dir !== 0 || p.flight === 0) vx = vx < target ? Math.min(target, vx + accel) : Math.max(target, vx - accel);
       p.facing = dir !== 0 ? dir : input.aimX >= p.body.getPosition().x * PPM ? 1 : -1;
     }
 
@@ -385,6 +553,8 @@ export class Game {
     }
     p.body.setLinearVelocity({ x: vx / PPM, y: vy / PPM });
 
+    if (pressed & Button.Use && p.item === 'candle') this.throwCandle(p, input);
+
     p.attackCooldown = Math.max(0, p.attackCooldown - TICK_DT);
     p.specialCooldown = Math.max(0, p.specialCooldown - TICK_DT);
     const ctx = this.contextFor(p, input);
@@ -400,6 +570,7 @@ export class Game {
   }
 
   private postStep(p: ServerPlayer): void {
+    if (p.dead > 0) return;
     p.grounded = false;
     for (let edge = p.body.getContactList(); edge; edge = edge.next) {
       const contact = edge.contact;
@@ -414,32 +585,96 @@ export class Game {
       }
     }
     p.sinceGrounded = p.grounded ? 0 : p.sinceGrounded + TICK_DT;
-    if (p.body.getPosition().y * PPM > this.terrain.height + OUT_OF_MAP_MARGIN) this.respawn(p);
+    // Landing ends the spoon flight; brushing an edge on the way up does not.
+    if (p.grounded && p.body.getLinearVelocity().y >= 0) p.flight = 0;
+    const y = p.body.getPosition().y * PPM;
+    if (y > this.custardY + CUSTARD_SINK_PX || y > this.terrain.height + OUT_OF_MAP_MARGIN) {
+      this.fx.push({ kind: 'splash', id: p.info.id, x: p.body.getPosition().x * PPM, y: this.custardY });
+      this.kill(p, 'custard');
+    }
   }
 
-  private respawn(p: ServerPlayer): void {
+  /** Out of the round for RESPAWN_DELAY_S: falls in the custard or crushed (readme §4). */
+  private kill(p: ServerPlayer, cause: 'custard' | 'crushed'): void {
+    if (this.cherry?.holder === p.info.id) this.dropCherry(p);
+    this.stats.add(p.info.id, cause);
+    p.dead = RESPAWN_DELAY_S;
+    p.stun = 0;
+    p.item = null;
+    p.body.setLinearVelocity({ x: 0, y: 0 });
+    p.body.setActive(false);
+  }
+
+  private placeAtSpawn(p: ServerPlayer): void {
     const [x, y] = this.map.spawns[this.nextSpawn++ % this.map.spawns.length];
     p.body.setPosition({ x: x / PPM, y: y / PPM });
     p.body.setLinearVelocity({ x: 0, y: 0 });
     p.stun = 0;
   }
 
-  private hit(attacker: ServerPlayer, x: number, y: number, r: number, knockback: number, stun: number): number {
+  /**
+   * The spoon catapult: from the custard surface, in a column of open air (so the player
+   * does not launch into the cake), as close as possible to a random spawn point.
+   */
+  private spoonLaunch(p: ServerPlayer): void {
+    p.dead = 0;
+    p.body.setActive(true);
+    const [sx, sy] = this.map.spawns[this.rng.int(0, this.map.spawns.length - 1)];
+    const surface = Math.min(this.custardY, this.terrain.height) - p.bodyRadius - 4;
+    const top = Math.max(0, Math.min(sy, surface - 200));
+    let bestX = -1;
+    for (let x = SPOON_COLUMN_STEP; x < this.terrain.width - SPOON_COLUMN_STEP; x += SPOON_COLUMN_STEP) {
+      if (bestX >= 0 && Math.abs(x - sx) >= Math.abs(bestX - sx)) continue;
+      if (this.columnIsFree(x, p.bodyRadius, top, surface)) bestX = x;
+    }
+    if (bestX < 0) {
+      this.placeAtSpawn(p);
+      return;
+    }
+    p.body.setPosition({ x: bestX / PPM, y: surface / PPM });
+    // Aim the arc at the spawn closest to the launch column (the random one may be across the map,
+    // out of reach): flight time ≈ 2·v/g.
+    let tx = sx;
+    for (const [x] of this.map.spawns) if (Math.abs(x - bestX) < Math.abs(tx - bestX)) tx = x;
+    const flight = (2 * SPOON_LAUNCH_SPEED) / GRAVITY_PX;
+    const vx = Math.max(-SPOON_MAX_VX, Math.min(SPOON_MAX_VX, (tx - bestX) / flight));
+    p.body.setLinearVelocity({ x: vx / PPM, y: -SPOON_LAUNCH_SPEED / PPM });
+    p.body.setAwake(true);
+    p.flight = flight;
+    this.fx.push({ kind: 'spoon', id: p.info.id, x: bestX, y: this.custardY });
+  }
+
+  private columnIsFree(x: number, radius: number, top: number, bottom: number): boolean {
+    for (let y = Math.floor(top); y <= bottom; y += 2) {
+      // Keep a margin around the body so the launch does not scrape a ledge.
+      for (const dx of [-radius - SPOON_CLEARANCE, -radius / 2, 0, radius / 2, radius + SPOON_CLEARANCE]) {
+        if (this.terrain.isSolid(Math.round(x + dx), y)) return false;
+      }
+    }
+    return true;
+  }
+
+  /** Knocks back and stuns players touching the disc. `attacker` is spared (null = everyone is hit, e.g. explosions). */
+  private hitArea(attacker: ServerPlayer | null, x: number, y: number, r: number, knockback: number, stun: number): number {
     let count = 0;
     for (const target of this.players.values()) {
-      if (target === attacker) continue;
+      if (target === attacker || target.dead > 0) continue;
       const pos = target.body.getPosition();
       const dx = pos.x * PPM - x;
       const dy = pos.y * PPM - y;
       const dist = Math.hypot(dx, dy);
       if (dist > r + target.bodyRadius) continue;
       // Push away from the hit point, always with some lift so the target pops up.
-      const nx = dist > 0 ? dx / dist : attacker.facing;
+      const nx = dist > 0 ? dx / dist : (attacker?.facing ?? 1);
       const ny = Math.min(dist > 0 ? dy / dist : 0, -0.4);
       const len = Math.hypot(nx, ny);
       target.body.setLinearVelocity({ x: ((nx / len) * knockback) / PPM, y: ((ny / len) * knockback) / PPM });
       target.stun = Math.max(target.stun, stun);
       target.character.onHit(this.contextFor(target, target.input));
+      // A hit holder lets the cherry go (readme §4).
+      if (this.cherry?.holder === target.info.id) this.dropCherry(target);
+      this.stats.add(target.info.id, 'hitTaken');
+      if (attacker) this.stats.add(attacker.info.id, 'hits');
       count++;
     }
     return count;
@@ -456,10 +691,202 @@ export class Game {
       grounded: p.grounded,
       facing: p.facing,
       input,
-      emitOp: (op, onApplied) => this.queueOp(op, onApplied),
+      emitOp: (op, onApplied) => this.queueOp(op, onApplied, p.info.id),
       setVelocity: (vx, vy) => p.body.setLinearVelocity({ x: vx / PPM, y: vy / PPM }),
       setSize: (radius) => this.resize(p, radius),
-      hit: (x, y, r, knockback, stun) => this.hit(p, x, y, r, knockback, stun),
+      hit: (x, y, r, knockback, stun) => this.hitArea(p, x, y, r, knockback, stun),
     };
   }
+
+  private alivePlayers(): ServerPlayer[] {
+    return [...this.players.values()].filter((p) => p.dead === 0);
+  }
+
+  // ---------------------------------------------------------------- round
+
+  private updateRound(): void {
+    if (!this.rules) return;
+    this.timeLeft -= TICK_DT;
+    const c = this.map.custard;
+    const speed = this.timeLeft <= FINAL_RUSH_S ? c.finalRushSpeed : c.speed;
+    this.custardY = Math.max(0, this.custardY - speed * TICK_DT);
+  }
+
+  private spawnCherry(): void {
+    const [x, y] = this.map.cherrySpawn;
+    const body = this.world.createBody({ type: 'dynamic', position: { x: x / PPM, y: y / PPM }, bullet: true });
+    body.createFixture(new Circle(CHERRY_RADIUS / PPM), { density: 0.6, friction: 0.5, restitution: 0.35, userData: { cherry: true } satisfies FixtureTag });
+    this.cherry = { body, holder: 0, blocked: 0, blockedFor: 0 };
+  }
+
+  private updateCherry(): void {
+    const cherry = this.cherry;
+    if (!cherry) return;
+    cherry.blockedFor = Math.max(0, cherry.blockedFor - TICK_DT);
+    const holder = cherry.holder ? this.players.get(cherry.holder) : undefined;
+    if (holder) {
+      // Held: it rides above the holder's head and scores 1 point per second.
+      const pos = holder.body.getPosition();
+      cherry.body.setPosition({ x: pos.x, y: pos.y - (holder.character.size + CHERRY_HOLD_OFFSET) / PPM });
+      holder.score += CHERRY_POINTS_PER_S * TICK_DT;
+      this.stats.add(holder.info.id, 'cherry', TICK_DT);
+      return;
+    }
+    cherry.holder = 0;
+    const pos = cherry.body.getPosition();
+    if (pos.y * PPM > this.custardY + CUSTARD_SINK_PX || pos.y * PPM > this.terrain.height + OUT_OF_MAP_MARGIN) {
+      this.fx.push({ kind: 'splash', id: 0, x: pos.x * PPM, y: this.custardY });
+      const [x, y] = this.map.cherrySpawn;
+      cherry.body.setPosition({ x: x / PPM, y: y / PPM });
+      cherry.body.setLinearVelocity({ x: 0, y: 0 });
+      return;
+    }
+    for (const p of this.alivePlayers()) {
+      if (p.stun > 0 || (cherry.blocked === p.info.id && cherry.blockedFor > 0)) continue;
+      const pp = p.body.getPosition();
+      if (Math.hypot(pp.x - pos.x, pp.y - pos.y) * PPM > p.bodyRadius + CHERRY_RADIUS + 4) continue;
+      cherry.holder = p.info.id;
+      cherry.body.setActive(false);
+      this.fx.push({ kind: 'pickup', id: p.info.id, item: 'cherry', x: pos.x * PPM, y: pos.y * PPM });
+      return;
+    }
+  }
+
+  /** The holder lets the cherry go: it pops out and rolls (readme §4). */
+  private dropCherry(p: ServerPlayer): void {
+    const cherry = this.cherry;
+    if (!cherry || cherry.holder !== p.info.id) return;
+    const pos = p.body.getPosition();
+    cherry.holder = 0;
+    cherry.blocked = p.info.id;
+    cherry.blockedFor = CHERRY_REGRAB_DELAY;
+    cherry.body.setActive(true);
+    cherry.body.setPosition({ x: pos.x, y: pos.y - (p.character.size + CHERRY_HOLD_OFFSET) / PPM });
+    const angle = -Math.PI / 2 + this.rng.range(-0.8, 0.8);
+    cherry.body.setLinearVelocity({ x: (Math.cos(angle) * CHERRY_DROP_SPEED) / PPM, y: (Math.sin(angle) * CHERRY_DROP_SPEED) / PPM });
+    cherry.body.setAwake(true);
+    this.fx.push({ kind: 'cherryDrop', id: p.info.id, x: pos.x * PPM, y: pos.y * PPM });
+  }
+
+  // ---------------------------------------------------------------- items
+
+  private nextItemDelay(): number {
+    const [a, b] = this.map.items.interval;
+    return this.rng.range(a, b);
+  }
+
+  private updateItems(): void {
+    if (this.rules) {
+      this.itemTimer -= TICK_DT;
+      if (this.itemTimer <= 0) {
+        this.itemTimer = this.nextItemDelay();
+        const weights: Partial<Record<ItemKind, number>> = {};
+        for (const k of ITEM_KINDS) if ((this.map.items.weights[k] ?? 0) > 0) weights[k] = this.map.items.weights[k];
+        if (Object.keys(weights).length > 0) {
+          const kind = this.rng.weighted(weights as Record<ItemKind, number>);
+          this.spawnItem(kind, this.rng.int(80, this.terrain.width - 80), -ITEM_RADIUS * 2);
+        }
+      }
+    }
+
+    for (const item of [...this.items.values()]) {
+      const pos = item.body.getPosition();
+      const x = pos.x * PPM;
+      const y = pos.y * PPM;
+      if (y > this.custardY + CUSTARD_SINK_PX || y > this.terrain.height + OUT_OF_MAP_MARGIN) {
+        this.fx.push({ kind: 'splash', id: 0, x, y: this.custardY });
+        this.removeItem(item);
+        continue;
+      }
+      if (item.thrown) {
+        item.thrown.fuse -= TICK_DT;
+        const armed = CANDLE_FUSE_S - item.thrown.fuse >= CANDLE_ARM_S;
+        if (item.thrown.fuse <= 0 || (armed && this.touchesAnything(item.body))) this.explode(item, x, y);
+        continue;
+      }
+      item.age += TICK_DT;
+      if (item.age > ITEM_LIFETIME_S) {
+        this.removeItem(item);
+        continue;
+      }
+      for (const p of this.alivePlayers()) {
+        const pp = p.body.getPosition();
+        if (Math.hypot(pp.x * PPM - x, pp.y * PPM - y) > p.bodyRadius + ITEM_RADIUS + 4) continue;
+        if (item.kind === 'candle' && p.item !== null) continue; // one candle at a time
+        this.pickUp(p, item, x, y);
+        break;
+      }
+    }
+  }
+
+  private pickUp(p: ServerPlayer, item: Item, x: number, y: number): void {
+    this.removeItem(item);
+    this.fx.push({ kind: 'pickup', id: p.info.id, item: item.kind, x, y });
+    if (item.kind === 'candle') p.item = 'candle';
+    else if (item.kind === 'smarties') p.boost = SMARTIES_DURATION_S;
+    else this.whisk(p);
+  }
+
+  private removeItem(item: Item): void {
+    this.world.destroyBody(item.body);
+    this.items.delete(item.id);
+  }
+
+  private touchesAnything(body: Body): boolean {
+    for (let edge = body.getContactList(); edge; edge = edge.next) if (edge.contact.isTouching()) return true;
+    return false;
+  }
+
+  private throwCandle(p: ServerPlayer, input: InputState): void {
+    p.item = null;
+    const pos = p.body.getPosition();
+    const px = pos.x * PPM;
+    const py = pos.y * PPM;
+    let dx = input.aimX - px;
+    let dy = input.aimY - py;
+    const len = Math.hypot(dx, dy) || 1;
+    dx /= len;
+    dy /= len;
+    const start = p.bodyRadius + ITEM_RADIUS + 2;
+    const id = this.spawnItem('candle', px + dx * start, py + dy * start);
+    const item = this.items.get(id)!;
+    item.thrown = { owner: p.info.id, fuse: CANDLE_FUSE_S };
+    const v = p.body.getLinearVelocity();
+    item.body.setLinearVelocity({ x: v.x + (dx * CANDLE_THROW_SPEED) / PPM, y: v.y + (dy * CANDLE_THROW_SPEED) / PPM });
+    item.body.setAngularVelocity(dx >= 0 ? 12 : -12);
+    this.stats.add(p.info.id, 'candles');
+  }
+
+  /** Candle explosion: carves the cake (chocolate resists) and blows everyone away, thrower included. */
+  private explode(item: Item, x: number, y: number): void {
+    const owner = item.thrown?.owner ?? 0;
+    this.removeItem(item);
+    this.queueOp({ kind: 'carve', x: Math.round(x), y: Math.round(y), r: CANDLE_BLAST_RADIUS, power: CANDLE_BLAST_POWER }, undefined, owner);
+    const attacker = this.players.get(owner);
+    const hits = this.hitArea(null, x, y, CANDLE_BLAST_RADIUS + 10, CANDLE_KNOCKBACK, CANDLE_STUN_S);
+    if (attacker) this.stats.add(owner, 'hits', hits);
+    this.fx.push({ kind: 'explosion', x, y, r: CANDLE_BLAST_RADIUS });
+  }
+
+  /** Electric whisk: jolts everyone else and shakes jelly loose, so weakened blocks come off (readme §4). */
+  private whisk(p: ServerPlayer): void {
+    this.fx.push({ kind: 'whisk', id: p.info.id });
+    for (const other of this.alivePlayers()) {
+      if (other === p) continue;
+      const v = other.body.getLinearVelocity();
+      other.body.setLinearVelocity({ x: v.x + this.rng.range(-0.5, 0.5) * (WHISK_JOLT / PPM), y: v.y - WHISK_JOLT / PPM });
+      other.stun = Math.max(other.stun, WHISK_STUN_S);
+      if (this.cherry?.holder === other.info.id) this.dropCherry(other);
+    }
+    const { width, height, data } = this.terrain;
+    for (let n = 0; n < WHISK_CRACKS; n++) {
+      for (let tries = 0; tries < 40; tries++) {
+        const x = this.rng.int(0, width - 1);
+        const y = this.rng.int(0, height - 1);
+        if (data[y * width + x] !== JELLY) continue;
+        this.queueOp({ kind: 'carve', x, y, r: WHISK_CRACK_RADIUS, power: WHISK_CRACK_POWER }, undefined, p.info.id);
+        break;
+      }
+    }
+  }
 }

+ 11 - 3
src/server/index.ts

@@ -1,11 +1,13 @@
 // Server entry point. Runs headless (`npm run server`) or inside an Electron utilityProcess.
 // Arguments: --map=<id> --port=<n> --seed=<n> --name=<game name>
+// 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 { Announcer } from './discovery';
-import { Game } from './game';
+import { Game, type RoundRules } from './game';
 import { GameServer } from './net';
+import type { MatchStats } from './stats';
 
 interface ParentPort {
   postMessage(message: unknown): void;
@@ -30,17 +32,23 @@ function main(): void {
   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): Game => {
+  const createGame = (gameSeed: number, rules: RoundRules | null, stats: MatchStats): Game => {
     const { definition, terrain } = loadMap(assetsDir, mapId, materials);
-    return new Game({ map: definition, terrain, materials, seed: gameSeed, loadCharacter: (id) => loadCharacterStats(assetsDir, id) });
+    return new Game({ map: definition, terrain, materials, seed: gameSeed, rules, stats, loadCharacter: (id) => loadCharacterStats(assetsDir, id) });
   };
 
   const serverId = randomUUID();
   let announcer: Announcer | null = null;
+  const num = (name: string): number | undefined => (arg(name) === undefined ? undefined : Number(arg(name)));
+  const pause = num('pause');
   const server = new GameServer({
     createGame,
     seed,
     port,
+    roundDuration: num('round'),
+    rounds: num('rounds'),
+    roundEndPause: pause,
+    matchEndPause: pause,
     onListening: () => {
       console.log(`[server] "${name}" on map "${mapId}" (seed ${seed}) listening on :${port}`);
       announcer = new Announcer(() => ({

+ 102 - 16
src/server/net.ts

@@ -3,17 +3,22 @@ import { WebSocketServer, type WebSocket } from 'ws';
 import { isCharacterId } from '../shared/characters';
 import {
   HASH_CHECK_INTERVAL_S,
+  MATCH_END_PAUSE_S,
   MAX_CATCHUP_TICKS,
   MAX_PLAYERS,
   PROTOCOL_VERSION,
+  ROUND_DURATION_S,
+  ROUND_END_PAUSE_S,
+  ROUNDS_PER_MATCH,
   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 { isClientMessage, type LobbyPlayer, type Phase, type RoundResult, type ServerMessage } from '../shared/protocol';
 import { Rng } from '../shared/rng';
-import type { Game } from './game';
+import type { Game, RoundRules } from './game';
+import { MatchStats } from './stats';
 
 const packr = new Packr({ useRecords: false });
 const HASH_CHECK_TICKS = HASH_CHECK_INTERVAL_S * TICK_RATE;
@@ -24,20 +29,25 @@ interface Client {
 }
 
 export interface GameServerOptions {
-  /** Builds a fresh game on the map for a new match. */
-  createGame: (seed: number) => Game;
+  /** 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;
   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;
+  // Match pacing, overridable for tests (seconds / count).
+  roundDuration?: number;
+  rounds?: number;
+  roundEndPause?: number;
+  matchEndPause?: number;
 }
 
 /**
- * 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.
+ * WebSocket transport + fixed-step loop around a Game, and the match flow (readme §4):
+ * lobby (warm-up, everyone ready) → rounds, each on a fresh map, with results in between
+ * → final results and trophies → back to the lobby, where one click restarts.
  */
 export class GameServer {
   private readonly wss: WebSocketServer;
@@ -47,12 +57,18 @@ export class GameServer {
   private phase: Phase = 'lobby';
   /** Ticks left before the match starts, or null. */
   private countdown: number | null = null;
+  /** Ticks left on the round results / final results screen. */
+  private pause = 0;
+  private round = 0;
+  private stats = new MatchStats();
+  /** Match totals by player id. */
+  private totals = new Map<number, number>();
   private nextPlayerId = 1;
   private timer: NodeJS.Timeout | null = null;
 
   constructor(private readonly opts: GameServerOptions) {
     this.rng = new Rng(opts.seed);
-    this.game = opts.createGame(this.rng.nextU32());
+    this.game = this.newGame(null);
     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) => {
@@ -80,6 +96,10 @@ export class GameServer {
     return typeof addr === 'object' && addr ? addr.port : this.opts.port;
   }
 
+  private get rounds(): number {
+    return this.opts.rounds ?? ROUNDS_PER_MATCH;
+  }
+
   start(): void {
     let last = performance.now();
     let acc = 0;
@@ -106,12 +126,28 @@ export class GameServer {
 
   /** Runs one tick by hand (tests). */
   tickOnce(): void {
+    if (this.phase === 'roundEnd' || this.phase === 'matchEnd') {
+      // Results on screen: the world is frozen.
+      if (--this.pause > 0) return;
+      if (this.phase === 'roundEnd') this.startRound();
+      else this.backToLobby();
+      return;
+    }
+
     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 });
     for (const c of result.crushed) this.broadcast({ t: 'crushed', ...c });
+    for (const ev of result.fx) this.broadcast({ t: 'fx', ev });
     if (this.game.tick % TICKS_PER_SNAPSHOT === 0) {
-      this.broadcast({ t: 'snapshot', tick: this.game.tick, players: this.game.snapshot(), chunks: this.game.chunkStates() });
+      this.broadcast({
+        t: 'snapshot',
+        tick: this.game.tick,
+        players: this.game.snapshot(),
+        chunks: this.game.chunkStates(),
+        items: this.game.itemStates(),
+        round: this.game.roundState(),
+      });
     }
     if (this.game.tick % HASH_CHECK_TICKS === 0) {
       this.broadcast({ t: 'hash', seq: this.game.events.length, hash: this.game.terrain.hash() });
@@ -121,12 +157,17 @@ export class GameServer {
       if (this.countdown <= 0) this.startMatch();
       else if (this.countdown % TICK_RATE === 0) this.broadcastLobby(); // one update per second shown
     }
+    if (this.phase === 'playing' && this.game.roundOver) this.endRound();
   }
 
   private players(): LobbyPlayer[] {
     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 onConnection(socket: WebSocket): void {
     const client: Client = { socket, player: null };
     this.clients.add(client);
@@ -158,7 +199,8 @@ export class GameServer {
         // 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.round = 0;
+        this.game = this.newGame(null);
       }
       this.updateCountdown();
       this.broadcastLobby();
@@ -187,6 +229,8 @@ export class GameServer {
       events: [...this.game.events],
       players: this.game.playerInfos(),
       chunks: this.game.chunkInfos(),
+      round: this.round,
+      rounds: this.rounds,
     });
     this.broadcast({ t: 'playerJoined', player: info }, client);
     // A newcomer is not ready: a running countdown stops.
@@ -208,16 +252,58 @@ export class GameServer {
     else if (!allReady) this.countdown = null;
   }
 
-  /** Reloads the map and puts every connected player in it. */
   private startMatch(): void {
     this.countdown = null;
+    this.round = 0;
+    this.stats = new MatchStats();
+    this.totals = new Map();
+    for (const p of this.players()) p.ready = false;
+    this.startRound();
+  }
+
+  /** Loads a fresh map and puts every connected player in it. */
+  private startRound(): void {
+    this.round++;
     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.game = this.newGame({ duration: this.opts.roundDuration ?? ROUND_DURATION_S });
+    this.populateAndAnnounce();
+  }
+
+  private endRound(): void {
+    const scores = this.game.scores();
+    for (const p of this.players()) this.totals.set(p.id, (this.totals.get(p.id) ?? 0) + (scores.get(p.id) ?? 0));
+    const results = this.results(scores);
+    if (this.round >= this.rounds) {
+      const lobbyIn = this.opts.matchEndPause ?? MATCH_END_PAUSE_S;
+      this.phase = 'matchEnd';
+      this.pause = Math.max(1, Math.round(lobbyIn * TICK_RATE));
+      this.broadcast({ t: 'matchEnd', results, trophies: this.stats.trophies(this.players().map((p) => p.id)), lobbyIn });
+    } else {
+      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 });
     }
-    this.broadcast({ t: 'start', mapId: this.game.map.id, seed: this.game.seed, players: this.game.playerInfos() });
+    this.broadcastLobby();
+  }
+
+  /** Results sorted by match total, then this round's score. */
+  private results(scores: Map<number, number>): RoundResult[] {
+    return this.players()
+      .map((p) => ({ id: p.id, name: p.name, characterId: p.characterId, roundScore: scores.get(p.id) ?? 0, total: this.totals.get(p.id) ?? 0 }))
+      .sort((a, b) => b.total - a.total || b.roundScore - a.roundScore || a.id - b.id);
+  }
+
+  private backToLobby(): void {
+    this.phase = 'lobby';
+    this.round = 0;
+    this.game = this.newGame(null);
+    this.populateAndAnnounce();
+  }
+
+  private populateAndAnnounce(): void {
+    for (const p of this.players()) 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(), round: this.round, rounds: this.rounds });
     this.broadcastLobby();
   }
 

+ 2 - 0
src/server/physics/fallingChunk.ts

@@ -9,6 +9,8 @@ const PPM = PIXELS_PER_METER;
 export interface FixtureTag {
   chunk?: number;
   player?: number;
+  item?: number;
+  cherry?: boolean;
 }
 
 interface BoxRect {

+ 63 - 0
src/server/stats.ts

@@ -0,0 +1,63 @@
+import type { Trophy } from '../shared/protocol';
+
+/** Per-player counters accumulated over a whole match, used for the trophies. */
+export type StatKey =
+  | 'eaten' // pixels removed from the terrain
+  | 'custard' // falls into the custard
+  | 'crushed' // times crushed by a falling chunk
+  | 'detached' // chunks this player made fall
+  | 'cherry' // seconds holding the cherry
+  | 'hits' // hits landed
+  | 'hitTaken' // hits received
+  | 'candles'; // candles thrown
+
+interface TrophyRule {
+  id: string;
+  score: (s: Record<StatKey, number>) => number;
+}
+
+/** Trophy rules (readme §4). Display names live in renderer/i18n. */
+const TROPHIES: TrophyRule[] = [
+  { id: 'glutton', score: (s) => s.eaten },
+  { id: 'kamikaze', score: (s) => s.custard },
+  { id: 'developer', score: (s) => s.detached },
+  { id: 'notMyFault', score: (s) => s.crushed + s.hitTaken },
+  { id: 'pyromaniac', score: (s) => s.candles },
+  { id: 'cherryPicker', score: (s) => Math.floor(s.cherry) },
+];
+
+const empty = (): Record<StatKey, number> => ({ eaten: 0, custard: 0, crushed: 0, detached: 0, cherry: 0, hits: 0, hitTaken: 0, candles: 0 });
+
+export class MatchStats {
+  private readonly data = new Map<number, Record<StatKey, number>>();
+
+  add(playerId: number, key: StatKey, n = 1): void {
+    if (playerId <= 0) return;
+    let s = this.data.get(playerId);
+    if (!s) this.data.set(playerId, (s = empty()));
+    s[key] += n;
+  }
+
+  get(playerId: number, key: StatKey): number {
+    return this.data.get(playerId)?.[key] ?? 0;
+  }
+
+  /** One trophy per rule, to the best player among `playerIds` (ties: lowest id), only if they did it at all. */
+  trophies(playerIds: number[]): Trophy[] {
+    const out: Trophy[] = [];
+    const ids = [...playerIds].sort((a, b) => a - b);
+    for (const rule of TROPHIES) {
+      let best = 0;
+      let bestId = -1;
+      for (const id of ids) {
+        const v = rule.score(this.data.get(id) ?? empty());
+        if (v > best) {
+          best = v;
+          bestId = id;
+        }
+      }
+      if (bestId > 0) out.push({ id: rule.id, playerId: bestId, value: Math.round(best) });
+    }
+    return out;
+  }
+}

+ 4 - 0
src/shared/characters/Character.ts

@@ -25,6 +25,10 @@ export const Anim = {
   Attack: 1 << 0,
   Special: 1 << 1,
   Stunned: 1 << 2,
+  /** In the custard, waiting for the spoon: not drawn. */
+  Dead: 1 << 3,
+  /** Smarties speed boost. */
+  Boosted: 1 << 4,
 } as const;
 
 /** What a character may read and do. Implemented by the server simulation. */

+ 43 - 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 = 4;
+export const PROTOCOL_VERSION = 5;
 
 export const NET_PORT = 47100;
 export const DISCOVERY_PORT = 47101;
@@ -15,6 +15,48 @@ export const HASH_CHECK_INTERVAL_S = 5;
 /** Countdown once every player in the lobby is ready (s). */
 export const START_COUNTDOWN_S = 3;
 
+// Match rules (readme §4). Tune by playing.
+export const ROUND_DURATION_S = 180;
+export const ROUNDS_PER_MATCH = 4;
+/** The custard rises at finalRushSpeed during the last seconds of a round. */
+export const FINAL_RUSH_S = 30;
+/** Pause on the round results before the next round (s). */
+export const ROUND_END_PAUSE_S = 7;
+/** Pause on the final results and trophies before going back to the lobby (s). */
+export const MATCH_END_PAUSE_S = 15;
+/** Seconds in the custard before the spoon catapult sends a player back. */
+export const RESPAWN_DELAY_S = 5;
+/** Launch speed of the spoon catapult (px/s). */
+export const SPOON_LAUNCH_SPEED = 950;
+/** A body sinks this far below the custard surface before it counts as fallen in (px). */
+export const CUSTARD_SINK_PX = 6;
+export const CHERRY_RADIUS = 12;
+export const CHERRY_POINTS_PER_S = 1;
+/** After dropping the cherry, its last holder cannot grab it back for this long (s). */
+export const CHERRY_REGRAB_DELAY = 1;
+/** Speed at which a dropped cherry pops out (px/s). */
+export const CHERRY_DROP_SPEED = 380;
+export const ITEM_RADIUS = 12;
+/** Items lying around for this long disappear (s). */
+export const ITEM_LIFETIME_S = 25;
+export const CANDLE_THROW_SPEED = 720;
+/** A thrown candle explodes on contact after arming, or when its fuse runs out (s). */
+export const CANDLE_ARM_S = 0.12;
+export const CANDLE_FUSE_S = 1.6;
+export const CANDLE_BLAST_RADIUS = 52;
+/** Below chocolate's hardness: chocolate resists explosions (readme §4). */
+export const CANDLE_BLAST_POWER = 50;
+export const CANDLE_KNOCKBACK = 760;
+export const CANDLE_STUN_S = 0.7;
+export const SMARTIES_DURATION_S = 5;
+export const SMARTIES_SPEED_MULT = 1.6;
+/** Electric whisk: number of jelly spots it shakes loose, and how hard players are jolted. */
+export const WHISK_CRACKS = 14;
+export const WHISK_CRACK_RADIUS = 11;
+export const WHISK_CRACK_POWER = 35;
+export const WHISK_JOLT = 320;
+export const WHISK_STUN_S = 0.35;
+
 export const TICK_RATE = 60;
 export const TICK_DT = 1 / TICK_RATE;
 export const SNAPSHOT_RATE = 30;

+ 2 - 0
src/shared/input.ts

@@ -6,6 +6,8 @@ export const Button = {
   Down: 1 << 3,
   Attack: 1 << 4,
   Special: 1 << 5,
+  /** Uses the carried item (throws the candle). */
+  Use: 1 << 6,
 } as const;
 
 export interface InputState {

+ 72 - 5
src/shared/protocol.ts

@@ -25,6 +25,63 @@ export interface PlayerState {
   size: number;
   /** Anim flags (shared/characters/Character.ts). */
   anim: number;
+  /** Points scored this round (whole seconds holding the cherry). */
+  score: number;
+  /** Item carried, waiting to be used. */
+  item: ItemKind | null;
+}
+
+export type ItemKind = 'candle' | 'smarties' | 'whisk';
+
+/** An item lying around, falling, or (candle) thrown and lit. */
+export interface ItemState {
+  id: number;
+  kind: ItemKind;
+  x: number;
+  y: number;
+  angle: number;
+  lit: boolean;
+}
+
+export interface CherryState {
+  x: number;
+  y: number;
+  /** Player holding it, 0 when it rolls free. */
+  holder: number;
+}
+
+/** Round-wide state carried by every snapshot. Null fields are inactive (warm-up). */
+export interface RoundState {
+  custardY: number;
+  /** Seconds left in the round, null during warm-up. */
+  timeLeft: number | null;
+  /** True during the last FINAL_RUSH_S seconds. */
+  finalRush: boolean;
+  cherry: CherryState | null;
+}
+
+/** One-shot visual/sound cues. */
+export type FxEvent =
+  | { kind: 'splash'; id: number; x: number; y: number }
+  | { kind: 'spoon'; id: number; x: number; y: number }
+  | { kind: 'explosion'; x: number; y: number; r: number }
+  | { kind: 'pickup'; id: number; item: ItemKind | 'cherry'; x: number; y: number }
+  | { kind: 'cherryDrop'; id: number; x: number; y: number }
+  | { kind: 'whisk'; id: number };
+
+export interface RoundResult {
+  id: number;
+  name: string;
+  characterId: string;
+  roundScore: number;
+  total: number;
+}
+
+/** A funny end-of-match award (names are in renderer/i18n). */
+export interface Trophy {
+  id: string;
+  playerId: number;
+  value: number;
 }
 
 /** A falling chunk's pose: (x, y) is its local origin (mask top-left) in map pixels. */
@@ -45,7 +102,8 @@ export interface ChunkInfo {
   state: ChunkState;
 }
 
-export type Phase = 'lobby' | 'playing';
+export const PHASES = ['lobby', 'playing', 'roundEnd', 'matchEnd'] as const;
+export type Phase = (typeof PHASES)[number];
 
 export interface LobbyPlayer {
   id: number;
@@ -82,7 +140,7 @@ export function isAnnouncement(msg: unknown): msg is Announcement {
     typeof m.maxPlayers === 'number' &&
     typeof m.version === 'number' &&
     typeof m.port === 'number' &&
-    (m.phase === 'lobby' || m.phase === 'playing')
+    PHASES.includes(m.phase as Phase)
   );
 }
 
@@ -106,6 +164,9 @@ export type ServerMessage =
       events: SequencedEvent[];
       players: PlayerInfo[];
       chunks: ChunkInfo[];
+      /** 0 during warm-up. */
+      round: number;
+      rounds: number;
     }
   | { t: 'reject'; reason: 'version' | 'full' | 'character'; serverVersion: number }
   | { t: 'playerJoined'; player: PlayerInfo }
@@ -114,10 +175,13 @@ 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[]; items: ItemState[]; round: RoundState }
+  | { t: 'fx'; ev: FxEvent }
   | { 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[] }
+  /** 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: '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 };
 
@@ -134,3 +198,6 @@ export function isClientMessage(msg: unknown): msg is ClientMessage {
   if (m.t === 'desync') return typeof m.seq === 'number' && typeof m.hash === 'number';
   return false;
 }
+
+export type RoundEndMessage = Extract<ServerMessage, { t: 'roundEnd' }>;
+export type MatchEndMessage = Extract<ServerMessage, { t: 'matchEnd' }>;

+ 2 - 1
tests/discovery.test.ts

@@ -35,7 +35,8 @@ describe('LAN discovery', () => {
     const announcer = new Announcer(() => announcement);
     announcer.start();
     await new Promise((r) => setTimeout(r, 400));
-    const games = listener.list();
+    // Other games may be running on this machine or the LAN: only look at ours.
+    const games = listener.list().filter((g) => g.serverId === 'test-server');
     announcer.stop();
     listener.stop();
     expect(games).toHaveLength(1);

+ 47 - 4
tests/net.test.ts

@@ -3,7 +3,7 @@ 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 { GameServer, type GameServerOptions } 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';
@@ -77,12 +77,13 @@ describe('LAN server (M3)', () => {
     server = null;
   });
 
-  async function startServer(): Promise<GameServer> {
+  async function startServer(pacing: Partial<GameServerOptions> = {}): Promise<GameServer> {
     await new Promise<void>((resolve) => {
       server = new GameServer({
-        createGame: (seed) => {
+        ...pacing,
+        createGame: (seed, rules, stats) => {
           const { definition, terrain } = loadMap(ASSETS, 'gateau-etages', materials);
-          return new Game({ map: definition, terrain, materials, seed, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
+          return new Game({ map: definition, terrain, materials, seed, rules, stats, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
         },
         seed: 42,
         port: 0,
@@ -175,4 +176,46 @@ describe('LAN server (M3)', () => {
     expect(s.currentPhase).toBe('lobby');
     expect(s.playerCount).toBe(0);
   });
+
+  it('a whole match: rounds with results in between, final results with trophies, then back to the lobby', async () => {
+    const s = await startServer({ rounds: 2, roundDuration: 1, roundEndPause: 0.25, matchEndPause: 0.25 });
+    const a = new TestClient(s.port);
+    clients.push(a);
+    await a.hello('Alice', 'castor');
+    await flush();
+    a.send({ t: 'ready', ready: true });
+    await flush();
+    tick(START_COUNTDOWN_S * TICK_RATE);
+    await flush();
+    expect(a.last('start')).toMatchObject({ round: 1, rounds: 2 });
+
+    // Alice eats the cake during round 1 so she earns a trophy.
+    for (let i = 0; i < TICK_RATE + 2; i++) {
+      const me = a.last('snapshot')?.players.find((p) => p.id === a.playerId);
+      if (me && i % 10 === 0) {
+        a.send({ t: 'input', buttons: Button.Special, aimX: Math.round(me.x), aimY: Math.round(me.y + 100) });
+        await flush(5);
+      }
+      tick(1);
+    }
+    await flush();
+    expect(a.last('roundEnd')).toMatchObject({ round: 1, rounds: 2 });
+    expect(a.last('lobby')).toMatchObject({ phase: 'roundEnd' });
+
+    tick(Math.round(0.25 * TICK_RATE));
+    await flush();
+    expect(a.last('start')).toMatchObject({ round: 2 });
+    tick(TICK_RATE + 2);
+    await flush();
+    const end = a.last('matchEnd')!;
+    expect(end).toBeDefined();
+    expect(end.results.map((r) => r.id)).toEqual([a.playerId]);
+    expect(end.trophies).toContainEqual(expect.objectContaining({ id: 'glutton', playerId: a.playerId }));
+
+    tick(Math.round(0.25 * TICK_RATE));
+    await flush();
+    expect(a.last('start')).toMatchObject({ round: 0 });
+    expect(a.last('lobby')).toMatchObject({ phase: 'lobby' });
+    expect(a.last('lobby')!.players[0].ready).toBe(false);
+  });
 });

+ 206 - 0
tests/rules.test.ts

@@ -0,0 +1,206 @@
+import { describe, expect, it } from 'vitest';
+import { loadCharacterStats, loadMap } from '../src/server/assets';
+import { Game } from '../src/server/game';
+import { MatchStats } from '../src/server/stats';
+import { Anim } from '../src/shared/characters';
+import { CHERRY_REGRAB_DELAY, FINAL_RUSH_S, RESPAWN_DELAY_S, SMARTIES_DURATION_S, TICK_RATE } from '../src/shared/constants';
+import { Button } from '../src/shared/input';
+import type { FxEvent } from '../src/shared/protocol';
+import { ASSETS, realMaterials } from './helpers';
+
+const materials = realMaterials();
+
+function newRound(duration = 180, seed = 1): Game {
+  const { definition, terrain } = loadMap(ASSETS, 'gateau-etages', materials);
+  return new Game({ map: definition, terrain, materials, seed, rules: { duration }, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
+}
+
+function run(game: Game, ticks: number): FxEvent[] {
+  const fx: FxEvent[] = [];
+  for (let i = 0; i < ticks; i++) fx.push(...game.step().fx);
+  return fx;
+}
+
+const me = (game: Game, id: number) => game.snapshot().find((p) => p.id === id)!;
+
+describe('custard and the spoon', () => {
+  it('rises during a round, faster in the final rush, and stays put during the warm-up', () => {
+    const round = newRound(40);
+    const start = round.custardLevel;
+    run(round, 5 * TICK_RATE);
+    const slow = start - round.custardLevel;
+    expect(slow).toBeCloseTo(round.map.custard.speed * 5, 0);
+    run(round, (40 - FINAL_RUSH_S) * TICK_RATE); // into the final rush
+    const before = round.custardLevel;
+    run(round, 5 * TICK_RATE);
+    expect(before - round.custardLevel).toBeCloseTo(round.map.custard.finalRushSpeed * 5, 0);
+    expect(round.roundState().finalRush).toBe(true);
+
+    const { definition, terrain } = loadMap(ASSETS, 'gateau-etages', materials);
+    const warmup = new Game({ map: definition, terrain, materials, seed: 1, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
+    run(warmup, 5 * TICK_RATE);
+    expect(warmup.custardLevel).toBe(definition.custard.startY);
+    expect(warmup.roundState()).toMatchObject({ timeLeft: null, cherry: null });
+  });
+
+  it('a player falling in is out for 5 s, then the spoon launches them back from open air', () => {
+    const game = newRound();
+    const { id } = game.addPlayer('Plouf', 'generic');
+    game.teleport(id, 100, 900); // left of the cake: nothing below but custard
+    const fx = run(game, TICK_RATE);
+    expect(fx).toContainEqual(expect.objectContaining({ kind: 'splash', id }));
+    expect(me(game, id).anim & Anim.Dead).toBeTruthy();
+    expect(game.stats.get(id, 'custard')).toBe(1);
+
+    const later = run(game, RESPAWN_DELAY_S * TICK_RATE);
+    const spoon = later.find((e) => e.kind === 'spoon');
+    expect(spoon).toBeDefined();
+    const p = me(game, id);
+    expect(p.anim & Anim.Dead).toBe(0);
+    expect(p.y).toBeLessThan(game.custardLevel);
+    // ...and lands on the cake instead of falling straight back in.
+    const flight = run(game, 3 * TICK_RATE);
+    expect(flight.filter((e) => e.kind === 'splash' && e.id === id)).toEqual([]);
+    expect(me(game, id).grounded).toBe(true);
+  });
+
+  it('the spoon lands players back on the cake whatever spawn it rolls (several seeds)', () => {
+    for (const seed of [1, 2, 3, 4, 5, 6]) {
+      const game = newRound(180, seed);
+      const { id } = game.addPlayer('Plouf', 'generic');
+      game.teleport(id, 100, 900);
+      run(game, (RESPAWN_DELAY_S + 0.5) * TICK_RATE);
+      const fx = run(game, 3 * TICK_RATE);
+      expect(fx.filter((e) => e.kind === 'splash' && e.id === id), `seed ${seed}`).toEqual([]);
+    }
+  });
+
+  it('the round ends when the timer runs out', () => {
+    const game = newRound(2);
+    run(game, 2 * TICK_RATE - 1);
+    expect(game.roundOver).toBe(false);
+    run(game, 2);
+    expect(game.roundOver).toBe(true);
+  });
+});
+
+describe('the cherry', () => {
+  it('is picked up by touching it and scores 1 point per second', () => {
+    const game = newRound();
+    const { id } = game.addPlayer('Gourmand', 'generic');
+    const [cx, cy] = game.map.cherrySpawn;
+    run(game, 30); // let the cherry settle on the top tier
+    const cherry = game.roundState().cherry!;
+    game.teleport(id, cherry.x, cherry.y - 10);
+    const fx = run(game, 10);
+    expect(fx).toContainEqual(expect.objectContaining({ kind: 'pickup', id, item: 'cherry' }));
+    expect(game.roundState().cherry!.holder).toBe(id);
+    run(game, 3 * TICK_RATE);
+    expect(me(game, id).score).toBeGreaterThanOrEqual(3);
+    expect([cx, cy]).toEqual(game.map.cherrySpawn);
+  });
+
+  it('a hit holder drops it, and cannot grab it back right away', () => {
+    const game = newRound();
+    const holder = game.addPlayer('Porteur', 'generic');
+    const biter = game.addPlayer('Castor', 'castor');
+    run(game, 30);
+    const cherry = game.roundState().cherry!;
+    game.teleport(holder.id, cherry.x, cherry.y - 10);
+    run(game, 10);
+    expect(game.roundState().cherry!.holder).toBe(holder.id);
+
+    const h = me(game, holder.id);
+    game.teleport(biter.id, h.x - 45, h.y);
+    game.setInput(biter.id, { buttons: Button.Attack, aimX: Math.round(h.x), aimY: Math.round(h.y) });
+    const fx = run(game, 2);
+    expect(fx).toContainEqual(expect.objectContaining({ kind: 'cherryDrop', id: holder.id }));
+    expect(game.roundState().cherry!.holder).not.toBe(holder.id);
+    expect(game.stats.get(biter.id, 'hits')).toBeGreaterThanOrEqual(1);
+    // Still blocked for the holder for a moment.
+    game.setInput(biter.id, { buttons: 0, aimX: 0, aimY: 0 });
+    run(game, Math.floor(CHERRY_REGRAB_DELAY * TICK_RATE * 0.5));
+    expect(game.roundState().cherry!.holder).not.toBe(holder.id);
+  });
+});
+
+describe('items', () => {
+  it('a candle is picked up, thrown with Use, and explodes: carves the cake and blows players away', () => {
+    const game = newRound();
+    const { id } = game.addPlayer('Artificier', 'generic');
+    run(game, 90);
+    const p = me(game, id);
+    game.spawnItem('candle', p.x, p.y - 30);
+    const fx = run(game, 20);
+    expect(fx).toContainEqual(expect.objectContaining({ kind: 'pickup', id, item: 'candle' }));
+    expect(me(game, id).item).toBe('candle');
+
+    const eventsBefore = game.events.length;
+    game.setInput(id, { buttons: Button.Use, aimX: Math.round(p.x + 60), aimY: Math.round(p.y + 80) });
+    const after = run(game, 2 * TICK_RATE);
+    expect(me(game, id).item).toBeNull();
+    expect(after).toContainEqual(expect.objectContaining({ kind: 'explosion' }));
+    expect(game.events.length).toBeGreaterThan(eventsBefore);
+    expect(game.stats.get(id, 'candles')).toBe(1);
+  });
+
+  it('smarties boost the speed for a while', () => {
+    const game = newRound();
+    const { id } = game.addPlayer('Speedy', 'generic');
+    run(game, 90);
+    const p = me(game, id);
+    game.spawnItem('smarties', p.x, p.y - 30);
+    run(game, 20);
+    expect(me(game, id).anim & Anim.Boosted).toBeTruthy();
+    run(game, SMARTIES_DURATION_S * TICK_RATE);
+    expect(me(game, id).anim & Anim.Boosted).toBe(0);
+  });
+
+  it('the electric whisk jolts the others and shakes the jelly', () => {
+    const game = newRound();
+    const a = game.addPlayer('Fouet', 'generic');
+    const b = game.addPlayer('Victime', 'generic');
+    run(game, 90);
+    const p = me(game, a.id);
+    const eventsBefore = game.events.length;
+    game.spawnItem('whisk', p.x, p.y - 30);
+    let whisked = false;
+    for (let i = 0; i < 30 && !whisked; i++) whisked = game.step().fx.some((e) => e.kind === 'whisk' && e.id === a.id);
+    expect(whisked).toBe(true);
+    // Same tick: the other player is jolted and stunned.
+    expect(me(game, b.id).anim & Anim.Stunned).toBeTruthy();
+    run(game, 2);
+    expect(game.events.length).toBeGreaterThan(eventsBefore + 5);
+    expect(game.stats.get(a.id, 'eaten')).toBeGreaterThan(0);
+  });
+
+  it('items fall from the sky on their own during a round (seeded)', () => {
+    const a = newRound(180, 7);
+    const b = newRound(180, 7);
+    // The first item drops within map.items.interval[1] seconds; both games drop the same ones.
+    let seen = 0;
+    for (let i = 0; i < a.map.items.interval[1] * TICK_RATE + 2; i++) {
+      a.step();
+      b.step();
+      seen = Math.max(seen, a.itemStates().length);
+    }
+    expect(seen).toBeGreaterThan(0);
+    expect(a.itemStates()).toEqual(b.itemStates());
+  });
+});
+
+describe('trophies', () => {
+  it('go to the best player of each stat, only if they did it', () => {
+    const stats = new MatchStats();
+    stats.add(1, 'eaten', 5000);
+    stats.add(2, 'eaten', 300);
+    stats.add(2, 'custard', 3);
+    stats.add(3, 'crushed', 1);
+    stats.add(3, 'hitTaken', 4);
+    const t = stats.trophies([1, 2, 3]);
+    expect(t).toContainEqual({ id: 'glutton', playerId: 1, value: 5000 });
+    expect(t).toContainEqual({ id: 'kamikaze', playerId: 2, value: 3 });
+    expect(t).toContainEqual({ id: 'notMyFault', playerId: 3, value: 5 });
+    expect(t.find((x) => x.id === 'pyromaniac')).toBeUndefined();
+  });
+});