瀏覽代碼

feat: playtest fixes - fair stuns, smart spoon, sound and options

Feedback from the first real LAN game.

Rules (server):
- No stun-lock: hits during a stun do not extend it, and a stun is
  followed by STUN_GUARD_S of immunity (with reduced knockback).
- The spoon aims its arc at a reachable ledge, checked against the
  terrain, even a last small island; blind throw only as a fallback.
- Round ends early when almost no cake is left (MIN_CAKE_PIXELS).
- K: give up when stuck (into the custard, back with the spoon).
- Round results last 20 s for the last-placed player's picks, cut to
  3 s once they are made.
- The host can stop the match (back to the warm-up); players can change
  character in the warm-up; a "random" pick is drawn every round.
- Players on the same character get a color variant.

Characters:
- Pyromanchot: firecrackers fly farther, backfire is a random chance
  with a smaller blast.
- Captain Prout: 3 thrusts before landing again.
- Gégé: higher jump, digs through chocolate.
- Bébé Pompier: soaked sponge gives way under players, the spray
  drenches (slows) the players it hits.
- Le Chat: clawed pounce that digs and sends players flying, stronger
  paw, rarer licks.
- Mamie's needles stun a bit less.

Client:
- All sounds synthesized with WebAudio (no files, no new dependency),
  per-material sounds from materials.json, positional; the birthday
  tune on a recorder, faster as the round goes.
- Options screen (Escape or the gear): volumes, mute, screen shake,
  host's stop, back to menu.
- The toxic cloud blur now goes through the dream filter: the nested
  BlurFilter could shrink the victim's screen.

Protocol version 10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Idleman 6 天之前
父節點
當前提交
a11d7249aa

+ 25 - 15
assets/characters/chat/character.json

@@ -8,24 +8,34 @@
   "size": 18,
   "cooldowns": {
     "attack": 0.3,
-    "special": 1.0
+    "special": 1.3
   },
   "params": {
-    "pawReach": 12,
-    "pawRadius": 16,
-    "pawKnockback": 480,
+    "pawReach": 14,
+    "pawRadius": 19,
+    "pawKnockback": 640,
     "pawStun": 0.3,
-    "pawCarve": 12,
-    "pawPower": 30,
+    "pawCarve": 14,
+    "pawPower": 40,
     "pawAnim": 0.15,
-    "shoveReach": 60,
-    "shoveRadius": 48,
-    "shoveSpeed": 700,
-    "shoveLift": 220,
-    "shoveFraction": 0.35,
-    "shoveAnim": 0.25,
-    "lickMin": 7,
-    "lickMax": 14,
-    "lickTime": 1
+    "pounceSpeed": 760,
+    "pounceLift": 60,
+    "pounceTime": 0.26,
+    "pounceHitReach": 4,
+    "pounceHitRadius": 20,
+    "pounceKnockback": 700,
+    "pounceStun": 0.45,
+    "scratchInterval": 0.04,
+    "scratchAhead": 2,
+    "scratchSpacing": 10,
+    "scratchRadius": 5,
+    "scratchPower": 40,
+    "shoveRadius": 50,
+    "shoveSpeed": 600,
+    "shoveLift": 240,
+    "shoveFraction": 0.7,
+    "lickMin": 14,
+    "lickMax": 24,
+    "lickTime": 0.8
   }
 }

+ 4 - 4
assets/characters/gege/character.json

@@ -1,9 +1,9 @@
 {
   "id": "gege",
   "name": "Gégé le Ver",
-  "speed": 165,
+  "speed": 190,
   "acceleration": 1500,
-  "jump": 520,
+  "jump": 620,
   "mass": 0.9,
   "size": 17,
   "cooldowns": {
@@ -18,10 +18,10 @@
     "headCarve": 10,
     "headPower": 30,
     "headAnim": 0.18,
-    "swimSpeed": 210,
+    "swimSpeed": 230,
     "swimGrace": 0.18,
     "swimMinPixels": 20,
-    "swimPower": 45,
+    "swimPower": 65,
     "digInterval": 0.05,
     "tunnelFactor": 1.15,
     "emergeRadiusFactor": 2.2,

+ 5 - 2
assets/characters/mamie/character.json

@@ -6,12 +6,15 @@
   "jump": 540,
   "mass": 1.1,
   "size": 20,
-  "cooldowns": { "attack": 0.32, "special": 1.1 },
+  "cooldowns": {
+    "attack": 0.32,
+    "special": 1.1
+  },
   "params": {
     "needleReach": 14,
     "needleRadius": 14,
     "needleKnockback": 260,
-    "needleStun": 0.7,
+    "needleStun": 0.5,
     "needleCarveRadius": 7,
     "needlePower": 15,
     "jabAnim": 0.15,

+ 3 - 2
assets/characters/prout/character.json

@@ -18,10 +18,11 @@
     "capeCarve": 12,
     "capePower": 30,
     "capeAnim": 0.2,
-    "puffLift": 430,
+    "puffLift": 400,
     "puffSide": 120,
     "cloudRadius": 46,
     "cloudLife": 3.5,
-    "puffAnim": 0.25
+    "puffAnim": 0.25,
+    "airPuffs": 3
   }
 }

+ 10 - 6
assets/characters/pyromanchot/character.json

@@ -6,22 +6,26 @@
   "jump": 600,
   "mass": 1,
   "size": 19,
-  "cooldowns": { "attack": 0.45, "special": 2.4 },
+  "cooldowns": {
+    "attack": 0.45,
+    "special": 2.4
+  },
   "params": {
-    "crackerSpeed": 620,
-    "crackerLift": 120,
-    "crackerFuse": 0.75,
+    "crackerSpeed": 820,
+    "crackerLift": 150,
+    "crackerFuse": 1.4,
     "crackerRadius": 26,
     "crackerPower": 40,
     "crackerKnockback": 520,
     "crackerStun": 0.4,
-    "backfireEvery": 3,
     "throwAnim": 0.2,
     "slideSpeed": 620,
     "slideTime": 0.55,
     "slideBlastRadius": 42,
     "slideBlastPower": 45,
     "slideKnockback": 680,
-    "slideStun": 0.5
+    "slideStun": 0.5,
+    "backfireChance": 0.12,
+    "backfireKnockback": 0.6
   }
 }

+ 21 - 6
readme.md

@@ -108,12 +108,15 @@ Toute la logique aléatoire (objets, Magicien Raté, mutateurs) passe par un **R
 - La cerise apparaît au sommet de la carte (position définie par la carte).
 - Tant qu'un joueur la tient, il marque **1 point par seconde**. S'il est touché, il la lâche et elle roule (objet physique).
 - **La crème anglaise monte** en continu. La vitesse est définie par la carte et accélère dans les 30 dernières secondes.
-- Un joueur qui tombe dans la crème réapparaît 5 secondes plus tard sur une **cuillère-catapulte** qui le renvoie sur le terrain.
+- Un joueur qui tombe dans la crème réapparaît 5 secondes plus tard sur une **cuillère-catapulte** qui le renvoie sur le terrain. La cuillère vise une corniche du gâteau qu'elle peut atteindre (trajectoire vérifiée contre le terrain), même s'il ne reste qu'un petit îlot.
+- Un joueur coincé peut **abandonner** (touche K) : il plonge dans la crème et revient par la cuillère.
+- La manche s'arrête plus tôt s'il ne reste presque plus de gâteau au-dessus de la crème (`MIN_CAKE_PIXELS`).
 - Des **objets tombent du ciel** à intervalles aléatoires :
   - **Bougie** : explosive, à lancer.
   - **Smarties** : boost de vitesse pendant 5 secondes.
   - **Fouet électrique** : secoue tout le terrain, ce qui fait trembler la gelée et détache les blocs fragilisés.
 - Il n'y a pas de barre de vie. Un coup fait **reculer** et peut **assommer** brièvement. On meurt uniquement en tombant dans la crème ou sous un bloc qui s'écrase.
+- **Pas d'assommage en boucle :** un coup reçu pendant qu'on est assommé ne prolonge pas l'assommage. Après un assommage, on est protégé pendant `STUN_GUARD_S` (les coups font alors moins reculer).
 ### Partie
  
 - 4 manches. Le score total décide du vainqueur.
@@ -145,11 +148,11 @@ Ils sont définis dans `src/shared/characters/` : une classe par personnage qui
 |---|---|---|---|
 | **Castor Glouton** | Coup de dents | Mange le décor en avançant (creuse) | Grossit en mangeant, jusqu'à passer à travers le sol |
 | **Mamie Tricot** | Aiguilles | Lance des pelotes qui créent ponts et filets (matériau Laine) | Lente, et ses constructions brûlent |
-| **Pyromanchot** | Pétards | Glissade sur le ventre avec explosion à l'arrivée | 1 pétard sur 3 lui explose dessus |
+| **Pyromanchot** | Pétards | Glissade sur le ventre avec explosion à l'arrivée | De temps en temps (`backfireChance`), un pétard lui explose dessus |
 | **Gégé le Ver** | Coup de tête | Nage dans le terrain et surgit sous les pieds | Lent et vulnérable à découvert |
-| **Bébé Pompier** | Jet d'eau (repousse) | Détrempe le terrain (Génoise → Génoise détrempée) | Le recul le propulse en arrière |
-| **Captain Prout** | Coup de cape | Jetpack par petites poussées, laisse un nuage toxique (vision floue + toux) | Chaque poussée s'entend |
-| **Le Chat** | Coup de patte | Pousse blocs, objets et joueurs assommés du bord | Se lèche la patte au hasard (1 s) |
+| **Bébé Pompier** | Jet d'eau (repousse) | Détrempe le terrain (Génoise → Génoise détrempée, qui cède sous les pieds) et trempe les joueurs arrosés (plus lents) | Le recul le propulse en arrière |
+| **Captain Prout** | Coup de cape | Jetpack par petites poussées (3 avant de reposer le pied), laisse un nuage toxique (vision floue + toux) | Chaque poussée s'entend |
+| **Le Chat** | Coup de patte qui griffe | Bond griffes en avant : creuse, éjecte qui il percute, puis pousse blocs, objets et joueurs | Se lèche la patte au hasard |
 | **Magicien Raté** | Baguette | Sort aléatoire : terrain en gelée, échange de position, lapin géant, « pfft » | Tirage pondéré : plus il est en tête, plus ses sorts ratent |
  
 API commune : `onAttack()`, `onSpecial()`, `onTick(dt)`, `onHit()`, et les stats (`speed`, `jump`, `mass`, `size`, `cooldowns`).
@@ -354,7 +357,19 @@ Chaque étape doit être **jouable** avant de passer à la suivante.
     - Les signatures (prout, jet, sortie de terre, sorts, léchouille) passent par un événement `cue` commun, prêt pour l'audio.
   - **DA, première passe finale :** terrain et fonds nets à toutes les résolutions, personnages en volume et plus expressifs, ambiance onirique (§7).
   - **Versions Windows autonomes :** `npm run dist` produit un exe portable, un zip et un installeur (§2). Ils ne sont pas signés et n'ont pas encore d'icône.
-- **Reste :** la suite de la DA (sprites dessinés, particules par matériau), l'audio, l'éditeur de cartes, et l'icône et la signature des exe.
+  - **Retours de la première vraie partie :**
+    - **Équilibrage :** plus d'assommage en boucle (§4). Pyromanchot : pétards qui vont plus loin, explosion ratée aléatoire et moins forte. Captain Prout : 3 poussées avant de reposer le pied. Gégé : saute plus haut et creuse le chocolat. Bébé Pompier : son détrempage piège ceux qui marchent dessus et ralentit ceux qu'il arrose. Le Chat : bond griffu, coup de patte plus fort, léchouilles plus rares.
+    - **Cuillère :** elle vise une corniche atteignable (§4).
+    - **Fin de manche anticipée** quand il ne reste presque plus de gâteau, et **abandon** avec K.
+    - **Hôte :** il peut arrêter la partie depuis les options pour revenir à l'échauffement. Pendant l'échauffement, chacun peut changer de perso.
+    - **Choix du dernier :** 20 s au lieu de 7, raccourcies à 3 s dès que les choix sont faits.
+    - **Perso au hasard :** une carte « Au hasard » tire un perso à chaque manche.
+    - **Couleurs :** les joueurs qui ont le même perso sont recolorés.
+    - **Cartes :** la Pile de Crêpes est refaite (crêpes épaisses, garnitures molles qu'on peut toutes creuser, une seconde petite pile). Des gaufrettes servent de corniches autour du Roulé, et les choux de la Pièce Montée ont le dessus plat.
+    - **Captain Prout :** le flou du nuage passe par le filtre onirique. Il ne rétrécit plus l'écran.
+  - **Audio :** tous les sons sont synthétisés en WebAudio (`renderer/audio/`), sans fichier. Chaque matériau joue le son nommé dans `materials.json` (`sfx`). Les sons sont spatialisés autour de la caméra et varient à chaque fois. La musique, « Joyeux anniversaire » à la flûte à bec, accélère au fil de la manche et pendant la montée finale.
+  - **Écran d'options** (Échap ou ⚙) : volumes, son coupé, secousses de l'écran. Les réglages sont gardés sur le PC (`renderer/settings.ts`).
+- **Reste :** la suite de la DA (sprites dessinés, particules par matériau), l'éditeur de cartes, et l'icône et la signature des exe.
 ---
  
 ## 10. Pour Claude : comment travailler sur ce projet

+ 328 - 0
src/renderer/audio/Audio.ts

@@ -0,0 +1,328 @@
+import { onSettings, type Settings } from '../settings';
+import { BirthdayFlute } from './music';
+
+// Every sound of the game is synthesized with WebAudio: no audio files to ship, and each one
+// varies a little every time it plays. Cosmetic only.
+
+/** Sounds the game can play, by name. Material sounds come from materials.json ("sfx"). */
+export type SoundName =
+  | 'chew'
+  | 'crack'
+  | 'splotch'
+  | 'wobble'
+  | 'crrrac'
+  | 'stick'
+  | 'puff'
+  | 'squish'
+  | 'clink'
+  | 'explosion'
+  | 'backfire'
+  | 'hit'
+  | 'jump'
+  | 'land'
+  | 'splash'
+  | 'spoon'
+  | 'pickup'
+  | 'cherry'
+  | 'cherryDrop'
+  | 'whisk'
+  | 'prout'
+  | 'water'
+  | 'emerge'
+  | 'lick'
+  | 'spell'
+  | 'pounce'
+  | 'crush'
+  | 'thud'
+  | 'beep'
+  | 'go'
+  | 'roundEnd'
+  | 'fanfare';
+
+/** Sounds farther than this from the listener (map px) are silent. */
+const HEARING_RANGE = 1500;
+/** Same sound again within this delay (s) is skipped: a dig of 30 ops makes one crunch, not 30. */
+const MIN_REPEAT_S = 0.05;
+
+export class GameAudio {
+  private ctx: AudioContext | null = null;
+  private master!: GainNode;
+  private sfxBus!: GainNode;
+  private musicBus!: GainNode;
+  private noise!: AudioBuffer;
+  private readonly lastPlayed = new Map<SoundName, number>();
+  private settings!: Settings;
+  private listenerX = 0;
+  private listenerY = 0;
+  readonly music = new BirthdayFlute();
+
+  constructor() {
+    // Browsers only start audio after a user gesture.
+    const unlock = (): void => {
+      this.ensure();
+      void this.ctx?.resume();
+    };
+    window.addEventListener('pointerdown', unlock);
+    window.addEventListener('keydown', unlock);
+    onSettings((s) => {
+      this.settings = s;
+      this.applyVolumes();
+    });
+  }
+
+  /** Where the camera is: sounds are panned and faded by their distance to it. */
+  setListener(x: number, y: number): void {
+    this.listenerX = x;
+    this.listenerY = y;
+  }
+
+  /** Plays a sound at a map position (or right here when x is undefined), `volume` 0..1. */
+  play(name: SoundName, x?: number, y?: number, volume = 1): void {
+    const ctx = this.ensure();
+    if (!ctx || ctx.state !== 'running' || this.settings.muted) return;
+    const now = ctx.currentTime;
+    if (now - (this.lastPlayed.get(name) ?? -1) < MIN_REPEAT_S) return;
+    this.lastPlayed.set(name, now);
+    let gain = volume;
+    let pan = 0;
+    if (x !== undefined && y !== undefined) {
+      const dx = x - this.listenerX;
+      const dist = Math.hypot(dx, y - this.listenerY);
+      gain *= Math.max(0, 1 - dist / HEARING_RANGE);
+      pan = Math.max(-0.8, Math.min(0.8, dx / 900));
+    }
+    if (gain < 0.02) return;
+    const out = ctx.createGain();
+    out.gain.value = gain;
+    const panner = ctx.createStereoPanner();
+    panner.pan.value = pan;
+    out.connect(panner).connect(this.sfxBus);
+    SOUNDS[name](new Synth(ctx, out, this.noise, now));
+  }
+
+  /** Starts, stops or speeds up the birthday tune (0 = calm, 1 = the custard is about to win). */
+  setMusic(playing: boolean, urgency = 0): void {
+    const ctx = this.ensure();
+    if (!ctx) return;
+    this.music.update(ctx, this.musicBus, playing, urgency);
+  }
+
+  private ensure(): AudioContext | null {
+    if (this.ctx) return this.ctx;
+    try {
+      this.ctx = new AudioContext();
+    } catch {
+      return null;
+    }
+    const ctx = this.ctx;
+    this.master = ctx.createGain();
+    // A soft limiter so stacked explosions do not clip.
+    const limiter = ctx.createDynamicsCompressor();
+    limiter.threshold.value = -10;
+    limiter.ratio.value = 8;
+    this.master.connect(limiter).connect(ctx.destination);
+    this.sfxBus = ctx.createGain();
+    this.musicBus = ctx.createGain();
+    this.sfxBus.connect(this.master);
+    this.musicBus.connect(this.master);
+    this.noise = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate);
+    const data = this.noise.getChannelData(0);
+    for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
+    this.applyVolumes();
+    return ctx;
+  }
+
+  private applyVolumes(): void {
+    if (!this.ctx || !this.settings) return;
+    const s = this.settings;
+    this.master.gain.value = s.muted ? 0 : s.master;
+    this.sfxBus.gain.value = s.sfx;
+    this.musicBus.gain.value = s.music;
+  }
+}
+
+/** Little helpers to build one sound, all scheduled from `t0`. */
+class Synth {
+  constructor(
+    readonly ctx: AudioContext,
+    readonly out: AudioNode,
+    private readonly noiseBuffer: AudioBuffer,
+    readonly t0: number,
+  ) {}
+
+  /** A random factor around 1, so repeated sounds never sound identical. */
+  vary(amount = 0.12): number {
+    return 1 + (Math.random() * 2 - 1) * amount;
+  }
+
+  /** An oscillator gliding from f0 to f1, with a quick attack and an exponential decay. */
+  tone(type: OscillatorType, f0: number, f1: number, start: number, length: number, volume: number, attack = 0.005): OscillatorNode {
+    const { ctx } = this;
+    const t = this.t0 + start;
+    const osc = ctx.createOscillator();
+    osc.type = type;
+    osc.frequency.setValueAtTime(f0, t);
+    osc.frequency.exponentialRampToValueAtTime(Math.max(1, f1), t + length);
+    const env = ctx.createGain();
+    env.gain.setValueAtTime(0.0001, t);
+    env.gain.exponentialRampToValueAtTime(volume, t + attack);
+    env.gain.exponentialRampToValueAtTime(0.0001, t + length);
+    osc.connect(env).connect(this.out);
+    osc.start(t);
+    osc.stop(t + length + 0.02);
+    return osc;
+  }
+
+  /** Filtered noise, the filter frequency gliding from f0 to f1. */
+  noise(filter: BiquadFilterType, f0: number, f1: number, start: number, length: number, volume: number, q = 1, attack = 0.003): void {
+    const { ctx } = this;
+    const t = this.t0 + start;
+    const src = ctx.createBufferSource();
+    src.buffer = this.noiseBuffer;
+    src.playbackRate.value = this.vary(0.2);
+    const bq = ctx.createBiquadFilter();
+    bq.type = filter;
+    bq.Q.value = q;
+    bq.frequency.setValueAtTime(f0, t);
+    bq.frequency.exponentialRampToValueAtTime(Math.max(20, f1), t + length);
+    const env = ctx.createGain();
+    env.gain.setValueAtTime(0.0001, t);
+    env.gain.exponentialRampToValueAtTime(volume, t + attack);
+    env.gain.exponentialRampToValueAtTime(0.0001, t + length);
+    src.connect(bq).connect(env).connect(this.out);
+    src.start(t, Math.random() * 0.5);
+    src.stop(t + length + 0.02);
+  }
+
+  /** Wobbles an oscillator's pitch (a fart, a buzz). */
+  wobble(osc: OscillatorNode, rate: number, depth: number, start: number, length: number): void {
+    const lfo = this.ctx.createOscillator();
+    lfo.frequency.value = rate;
+    const amount = this.ctx.createGain();
+    amount.gain.value = depth;
+    lfo.connect(amount).connect(osc.frequency);
+    lfo.start(this.t0 + start);
+    lfo.stop(this.t0 + start + length + 0.02);
+  }
+}
+
+const SOUNDS: Record<SoundName, (s: Synth) => void> = {
+  // Materials (materials.json "sfx").
+  chew: (s) => {
+    for (let i = 0; i < 3; i++) s.noise('lowpass', 900 * s.vary(), 300, i * 0.07, 0.06, 0.5);
+  },
+  crack: (s) => {
+    s.noise('highpass', 2500, 1200, 0, 0.05, 0.6);
+    s.tone('square', 220 * s.vary(), 90, 0, 0.06, 0.15);
+  },
+  splotch: (s) => {
+    s.noise('lowpass', 700, 150, 0, 0.18, 0.6);
+    s.tone('sine', 320 * s.vary(), 70, 0, 0.18, 0.35);
+  },
+  wobble: (s) => {
+    const o = s.tone('sine', 260 * s.vary(), 200, 0, 0.3, 0.35, 0.02);
+    s.wobble(o, 14, 40, 0, 0.3);
+  },
+  crrrac: (s) => {
+    for (let i = 0; i < 4; i++) s.noise('bandpass', 2200 * s.vary(0.3), 1400, i * 0.035, 0.04, 0.55, 2);
+  },
+  stick: (s) => {
+    s.noise('lowpass', 500, 200, 0, 0.25, 0.4, 4, 0.03);
+    s.tone('triangle', 140 * s.vary(), 90, 0, 0.25, 0.2, 0.04);
+  },
+  puff: (s) => s.noise('lowpass', 1200, 300, 0, 0.15, 0.4),
+  squish: (s) => {
+    s.noise('bandpass', 600, 250, 0, 0.2, 0.6, 3, 0.02);
+    s.tone('sine', 180 * s.vary(), 110, 0, 0.2, 0.25, 0.03);
+  },
+  clink: (s) => {
+    s.tone('sine', 2400 * s.vary(0.05), 2300, 0, 0.35, 0.25);
+    s.tone('sine', 3700 * s.vary(0.05), 3600, 0, 0.25, 0.12);
+  },
+  // Action.
+  explosion: (s) => {
+    s.noise('lowpass', 3000, 120, 0, 0.8, 1, 0.7);
+    s.tone('sine', 140 * s.vary(), 35, 0, 0.6, 0.9);
+    s.noise('highpass', 3000, 2000, 0, 0.08, 0.4);
+  },
+  backfire: (s) => {
+    s.noise('lowpass', 2000, 150, 0, 0.45, 0.8, 0.7);
+    s.tone('sine', 120, 40, 0, 0.4, 0.7);
+    // "Aïe !": a little squeak.
+    s.tone('square', 900, 600, 0.18, 0.14, 0.12, 0.01);
+  },
+  hit: (s) => {
+    s.tone('sine', 700 * s.vary(), 180, 0, 0.14, 0.6);
+    s.noise('bandpass', 1500, 800, 0, 0.05, 0.5, 2);
+  },
+  jump: (s) => s.tone('triangle', 260 * s.vary(0.08), 620, 0, 0.14, 0.22, 0.01),
+  land: (s) => s.tone('sine', 140 * s.vary(), 60, 0, 0.1, 0.35),
+  splash: (s) => {
+    s.noise('lowpass', 1800, 200, 0, 0.45, 0.8, 1, 0.01);
+    s.tone('sine', 420 * s.vary(), 80, 0, 0.3, 0.5);
+    for (let i = 0; i < 4; i++) s.tone('sine', 900 * s.vary(0.4), 1300, 0.1 + i * 0.06, 0.05, 0.15);
+  },
+  spoon: (s) => {
+    const o = s.tone('sawtooth', 160, 900, 0, 0.45, 0.25, 0.01);
+    s.wobble(o, 25, 30, 0, 0.45);
+    s.tone('sine', 1800, 1800, 0, 0.3, 0.1);
+  },
+  pickup: (s) => {
+    [523, 659, 784].forEach((f, i) => s.tone('triangle', f, f, i * 0.06, 0.12, 0.3));
+  },
+  cherry: (s) => {
+    [784, 988, 1175, 1568].forEach((f, i) => s.tone('triangle', f, f, i * 0.07, 0.18, 0.32));
+  },
+  cherryDrop: (s) => {
+    [784, 622, 494].forEach((f, i) => s.tone('triangle', f, f * 0.95, i * 0.08, 0.14, 0.3));
+  },
+  whisk: (s) => {
+    const o = s.tone('sawtooth', 110, 130, 0, 0.7, 0.3, 0.02);
+    s.wobble(o, 40, 25, 0, 0.7);
+    s.noise('highpass', 4000, 5000, 0, 0.7, 0.15, 1, 0.02);
+  },
+  prout: (s) => {
+    // Every fart is different (readme §7: "prouts variés").
+    const f = 70 + Math.random() * 60;
+    const length = 0.22 + Math.random() * 0.3;
+    const o = s.tone('sawtooth', f, f * (0.6 + Math.random() * 0.3), 0, length, 0.55, 0.02);
+    s.wobble(o, 18 + Math.random() * 30, f * 0.35, 0, length);
+    s.noise('lowpass', 400, 150, 0, length, 0.3, 2, 0.02);
+  },
+  water: (s) => s.noise('highpass', 1800, 3500, 0, 0.35, 0.45, 0.7, 0.02),
+  emerge: (s) => {
+    s.noise('lowpass', 1200, 300, 0, 0.25, 0.6);
+    s.tone('square', 300, 900, 0.05, 0.2, 0.15, 0.01);
+  },
+  lick: (s) => {
+    for (let i = 0; i < 2; i++) s.noise('bandpass', 900, 2200, i * 0.22, 0.18, 0.35, 5, 0.03);
+  },
+  spell: (s) => {
+    for (let i = 0; i < 6; i++) s.tone('sine', 1200 + Math.random() * 1800, 2600, i * 0.05, 0.12, 0.15);
+  },
+  pounce: (s) => {
+    // "Mraow!"
+    const o = s.tone('sawtooth', 500, 380, 0, 0.28, 0.18, 0.03);
+    o.frequency.setValueAtTime(500, s.t0);
+    o.frequency.linearRampToValueAtTime(780, s.t0 + 0.1);
+    o.frequency.linearRampToValueAtTime(420, s.t0 + 0.28);
+    s.noise('highpass', 3000, 3000, 0.05, 0.12, 0.2, 1, 0.01);
+  },
+  crush: (s) => {
+    s.noise('lowpass', 900, 100, 0, 0.5, 1, 1, 0.005);
+    s.tone('sine', 180, 40, 0, 0.45, 0.8);
+  },
+  thud: (s) => {
+    s.tone('sine', 110 * s.vary(), 45, 0, 0.3, 0.7);
+    s.noise('lowpass', 600, 100, 0, 0.25, 0.5);
+  },
+  // Interface.
+  beep: (s) => s.tone('square', 660, 660, 0, 0.12, 0.15),
+  go: (s) => s.tone('square', 990, 990, 0, 0.35, 0.18),
+  roundEnd: (s) => {
+    [784, 659, 523, 392].forEach((f, i) => s.tone('triangle', f, f, i * 0.12, 0.25, 0.3));
+  },
+  fanfare: (s) => {
+    [523, 659, 784, 1047, 784, 1047].forEach((f, i) => s.tone('square', f, f, i * 0.12, i === 5 ? 0.6 : 0.14, 0.14));
+  },
+};

+ 136 - 0
src/renderer/audio/music.ts

@@ -0,0 +1,136 @@
+// The game's tune (readme §7): "Joyeux anniversaire" on a recorder, with an oom-pah-pah bass,
+// speeding up as the custard rises. The melody is traditional (public domain).
+
+/** Melody as [midi note, beats]; 0 = rest. A pickup beat, eight bars of 3/4, then a bar of rest. */
+const MELODY: [number, number][] = [
+  [67, 0.75], [67, 0.25],
+  [69, 1], [67, 1], [72, 1],
+  [71, 2], [67, 0.75], [67, 0.25],
+  [69, 1], [67, 1], [74, 1],
+  [72, 2], [67, 0.75], [67, 0.25],
+  [79, 1], [76, 1], [72, 1],
+  [71, 1], [69, 1], [77, 0.75], [77, 0.25],
+  [76, 1], [72, 1], [74, 1],
+  [72, 3],
+  [0, 3],
+];
+/** Chord root of each bar after the pickup (midi), for the bass. */
+const BARS = [48, 43, 43, 48, 48, 41, 43, 48, 48];
+const BEATS_PER_BAR = 3;
+
+const BASE_BPM = 112;
+const RUSH_BPM = 180;
+/** Notes are scheduled this far ahead (s), by a timer firing every SCHEDULE_MS. */
+const LOOKAHEAD_S = 0.25;
+const SCHEDULE_MS = 60;
+
+const hz = (midi: number): number => 440 * 2 ** ((midi - 69) / 12);
+
+export class BirthdayFlute {
+  private timer: ReturnType<typeof setInterval> | null = null;
+  private nextTime = 0;
+  /** Index in MELODY of the next note, and the beat it starts on (from the start of the tune). */
+  private note = 0;
+  private beat = 0;
+  private urgency = 0;
+  private ctx: AudioContext | null = null;
+  private out: AudioNode | null = null;
+
+  update(ctx: AudioContext, out: AudioNode, playing: boolean, urgency: number): void {
+    this.urgency = Math.max(0, Math.min(1, urgency));
+    if (playing && !this.timer) {
+      this.ctx = ctx;
+      this.out = out;
+      this.note = 0;
+      this.beat = 0;
+      this.nextTime = ctx.currentTime + 0.1;
+      this.timer = setInterval(() => this.schedule(), SCHEDULE_MS);
+    } else if (!playing && this.timer) {
+      clearInterval(this.timer);
+      this.timer = null;
+    }
+  }
+
+  private secondsPerBeat(): number {
+    return 60 / (BASE_BPM + (RUSH_BPM - BASE_BPM) * this.urgency);
+  }
+
+  private schedule(): void {
+    const ctx = this.ctx;
+    const out = this.out;
+    if (!ctx || !out || ctx.state !== 'running') return;
+    const horizon = ctx.currentTime + LOOKAHEAD_S;
+    // Fell behind (tab hidden...): skip ahead instead of playing a burst of notes.
+    if (this.nextTime < ctx.currentTime - 0.5) {
+      this.nextTime = ctx.currentTime + 0.05;
+    }
+    while (this.nextTime < horizon) {
+      const [midi, beats] = MELODY[this.note];
+      const spb = this.secondsPerBeat();
+      if (midi > 0) this.flute(ctx, out, hz(midi), this.nextTime, beats * spb);
+      // The bass beats falling during this note, at the same tempo (they never drift apart).
+      for (let k = Math.ceil(this.beat); k < this.beat + beats; k++) {
+        // Beat 0 is the pickup; then bars of 3 beats, the last one silent.
+        const bar = Math.floor((k - 1) / BEATS_PER_BAR);
+        if (k < 1 || bar >= BARS.length - 1) continue;
+        const inBar = (k - 1) % BEATS_PER_BAR;
+        const root = BARS[bar];
+        // Oom on the root, pah-pah on the fifth an octave up.
+        this.bass(ctx, out, hz(inBar === 0 ? root : root + 19), this.nextTime + (k - this.beat) * spb, inBar === 0 ? 0.32 : 0.14);
+      }
+      this.nextTime += beats * spb;
+      this.beat += beats;
+      this.note++;
+      if (this.note >= MELODY.length) {
+        this.note = 0;
+        this.beat = 0;
+      }
+    }
+  }
+
+  /** A recorder: a soft sine with a touch of triangle, slight vibrato, and breath noise. */
+  private flute(ctx: AudioContext, out: AudioNode, f: number, t: number, length: number): void {
+    const dur = Math.max(0.08, length * 0.92);
+    const env = ctx.createGain();
+    env.gain.setValueAtTime(0.0001, t);
+    env.gain.exponentialRampToValueAtTime(0.22, t + 0.03);
+    env.gain.setValueAtTime(0.18, t + dur * 0.6);
+    env.gain.exponentialRampToValueAtTime(0.0001, t + dur);
+    env.connect(out);
+    const vibrato = ctx.createOscillator();
+    vibrato.frequency.value = 5.5;
+    const depth = ctx.createGain();
+    depth.gain.value = f * 0.006;
+    vibrato.connect(depth);
+    for (const [type, level] of [
+      ['sine', 1],
+      ['triangle', 0.25],
+    ] as const) {
+      const osc = ctx.createOscillator();
+      osc.type = type;
+      osc.frequency.value = type === 'sine' ? f : f * 2;
+      depth.connect(osc.frequency);
+      const g = ctx.createGain();
+      g.gain.value = level;
+      osc.connect(g).connect(env);
+      osc.start(t);
+      osc.stop(t + dur + 0.02);
+    }
+    vibrato.start(t);
+    vibrato.stop(t + dur + 0.02);
+  }
+
+  /** A plucked bass note. */
+  private bass(ctx: AudioContext, out: AudioNode, f: number, t: number, volume: number): void {
+    const osc = ctx.createOscillator();
+    osc.type = 'triangle';
+    osc.frequency.value = f;
+    const env = ctx.createGain();
+    env.gain.setValueAtTime(0.0001, t);
+    env.gain.exponentialRampToValueAtTime(volume, t + 0.01);
+    env.gain.exponentialRampToValueAtTime(0.0001, t + 0.3);
+    osc.connect(env).connect(out);
+    osc.start(t);
+    osc.stop(t + 0.32);
+  }
+}

+ 4 - 2
src/renderer/config.ts

@@ -46,8 +46,6 @@ export const SHAKE_PER_PIXEL = 0.004;
 export const SHAKE_MAX = 12;
 export const SHAKE_DECAY = 20;
 export const HIT_SHAKE = 8;
-/** Blur strength of the world while the local player is in a toxic cloud. */
-export const POISON_BLUR = 6;
 export const MERGE_SHAKE = 6;
 export const CRUSH_SHAKE = 12;
 export const PLAYER_SPLAT_COLOR = 0xff6f91;
@@ -66,4 +64,8 @@ export const INPUT_SEND_HZ = 60;
 export const SQUASH_RECOVERY = 10;
 /** Max forward lean (radians) of a running character. */
 export const RUN_LEAN = 0.12;
+/** Hue shifts (degrees) of the 2nd, 3rd... player on the same character. */
+export const VARIANT_HUES = [150, 240, 60, 300, 100, 200, 330];
+/** Resolution of the hue filter on those players (high enough to stay sharp under the dream filter). */
+export const VARIANT_FILTER_RESOLUTION = 4;
 export const PLAYER_COLORS = [0xff6f91, 0x4dc4ff, 0x9be15d, 0xffc94d, 0xb18cff, 0xff9a4d, 0x4de0c4, 0xf15bb5];

+ 22 - 4
src/renderer/entities/PlayerView.ts

@@ -1,9 +1,9 @@
-import { Container, Graphics, Text } from 'pixi.js';
+import { ColorMatrixFilter, 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, RUN_LEAN, SQUASH_RECOVERY } from '../config';
+import { OUTLINE_COLOR, OUTLINE_WIDTH, PLAYER_COLORS, RUN_LEAN, SQUASH_RECOVERY, VARIANT_FILTER_RESOLUTION, VARIANT_HUES } from '../config';
 
 /** Max size the fat ratio is normalized against (castor maxSize is in its JSON; this only drives the cheeks). */
 const FAT_RANGE = 26;
@@ -11,6 +11,7 @@ const FAT_RANGE = 26;
 export interface PlayerEvents {
   landed: boolean;
   landingSpeed: number;
+  jumped: boolean;
   stunnedNow: boolean;
 }
 
@@ -42,6 +43,12 @@ export class PlayerView {
     this.name.anchor.set(0.5, 1);
     this.held.scale.set(0.8);
     this.container.addChild(this.shadow, this.body, this.held, this.name);
+    // Several players on the same character: the others get their colors shifted.
+    if (info.variant > 0) {
+      const filter = new ColorMatrixFilter({ resolution: VARIANT_FILTER_RESOLUTION, antialias: 'on' });
+      filter.hue(VARIANT_HUES[(info.variant - 1) % VARIANT_HUES.length], false);
+      this.body.filters = [filter];
+    }
   }
 
   /** Draws a static pose (menu portraits). */
@@ -64,12 +71,15 @@ export class PlayerView {
     }
 
     // Landing squashes, jumping stretches.
-    const events: PlayerEvents = { landed: false, landingSpeed: 0, stunnedNow: false };
+    const events: PlayerEvents = { landed: false, landingSpeed: 0, jumped: false, stunnedNow: false };
     if (state.grounded && !this.wasGrounded) {
       this.squash = Math.min(0.45, Math.abs(this.lastVy) / 1600);
       events.landed = true;
       events.landingSpeed = Math.abs(this.lastVy);
-    } else if (!state.grounded && this.wasGrounded && state.vy < -100) this.squash = -0.3;
+    } else if (!state.grounded && this.wasGrounded && state.vy < -100) {
+      this.squash = -0.3;
+      events.jumped = true;
+    }
     this.wasGrounded = state.grounded;
     this.lastVy = state.vy;
     const stunned = (state.anim & Anim.Stunned) !== 0;
@@ -97,6 +107,14 @@ export class PlayerView {
       busy: (state.anim & Anim.Busy) !== 0,
       underground: (state.anim & Anim.Underground) !== 0,
     });
+    // Drenched: dripping wet.
+    if (state.anim & Anim.Soaked) {
+      for (let i = 0; i < 3; i++) {
+        const t = (this.time * 1.6 + i / 3) % 1;
+        const x = (i - 1) * size * 0.55;
+        this.shadow.ellipse(x, -size * 0.3 + t * size * 1.2, size * 0.07, size * 0.11).fill({ color: 0x4dc4ff, alpha: 0.85 * (1 - t) });
+      }
+    }
     return events;
   }
 

+ 10 - 1
src/renderer/fx/Dream.ts

@@ -17,6 +17,7 @@ uniform float uRadius;
 uniform float uDesat;
 uniform float uLift;
 uniform vec3 uTint;
+uniform float uPoison;
 
 void main() {
   vec4 base = texture(uTexture, vTextureCoord);
@@ -25,7 +26,7 @@ void main() {
   float wsum = 0.0;
   for (int i = 0; i < 16; i++) {
     float fi = float(i);
-    float r = sqrt((fi + 0.5) / 16.0) * uRadius;
+    float r = sqrt((fi + 0.5) / 16.0) * uRadius * (1.0 + uPoison * 1.8);
     float a = fi * 2.39996;
     vec2 uv = clamp(vTextureCoord + vec2(cos(a), sin(a)) * r * uInputSize.zw, uInputClamp.xy, uInputClamp.zw);
     float w = 1.0 - fi / 24.0;
@@ -40,6 +41,8 @@ void main() {
   c = mix(c, glow, uGlow);
   // Soft focus: a veil of the blur everywhere, so every edge melts a little.
   c = mix(c, max(c, blur), uSoft);
+  // Toxic cloud: the poisoned player's sight goes blurry.
+  c = mix(c, blur, uPoison * 0.85);
   // Pastel grade: a little less saturated, shadows lifted toward a warm lilac.
   float l = dot(c, vec3(0.299, 0.587, 0.114));
   c = mix(c, vec3(l), uDesat);
@@ -64,6 +67,7 @@ export function makeDreamFilter(resolution: number): Filter {
         uDesat: { value: DREAM_DESAT, type: 'f32' },
         uLift: { value: DREAM_LIFT, type: 'f32' },
         uTint: { value: new Float32Array([(t >> 16) / 255, ((t >> 8) & 0xff) / 255, (t & 0xff) / 255]), type: 'vec3<f32>' },
+        uPoison: { value: 0, type: 'f32' },
       }),
     },
     resolution,
@@ -71,6 +75,11 @@ export function makeDreamFilter(resolution: number): Filter {
   });
 }
 
+/** Blurs the dream filter's output (0..1) while the local player is in a toxic cloud. */
+export function setDreamPoison(filter: Filter, amount: number): void {
+  (filter.resources.dreamUniforms as UniformGroup).uniforms.uPoison = amount;
+}
+
 /** A soft round glow, white in the middle. */
 function glowTexture(): Texture {
   const size = 64;

+ 40 - 15
src/renderer/i18n/fr.ts

@@ -14,15 +14,15 @@ export const fr = {
   rejectedCharacter: 'Ce personnage n’existe pas sur ce serveur.',
   loadError: (msg: string) => `Impossible de charger la partie : ${msg}`,
   help: {
-    mamie: 'Q/D : courir · Espace/Z : sauter · Clic gauche : aiguilles · Clic droit : lancer une pelote · F : lancer la bougie',
-    pyromanchot: 'Q/D : courir · Espace/Z : sauter · Clic gauche : pétard · Clic droit : glissade explosive · F : lancer la bougie',
-    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',
-    gege: 'Q/D : ramper · Espace/Z : sauter · Clic gauche : coup de tête · Clic droit (maintenu) : nager dans le gâteau · F : lancer la bougie',
-    pompier: 'Q/D : courir · Espace/Z : sauter · Clic gauche : jet d’eau · Clic droit : détremper · F : lancer la bougie',
-    prout: 'Q/D : courir · Espace/Z : sauter · Clic gauche : coup de cape · Clic droit : propulsion · F : lancer la bougie',
-    chat: 'Q/D : courir · Espace/Z : sauter · Clic gauche : coup de patte · Clic droit : pousser · F : lancer la bougie',
-    magicien: 'Q/D : courir · Espace/Z : sauter · Clic gauche : baguette · Clic droit : lancer un sort · F : lancer la bougie',
+    mamie: 'Q/D : courir · Espace/Z : sauter · Clic gauche : aiguilles · Clic droit : lancer une pelote · F : lancer la bougie · K : abandonner',
+    pyromanchot: 'Q/D : courir · Espace/Z : sauter · Clic gauche : pétard · Clic droit : glissade explosive · F : lancer la bougie · K : abandonner',
+    generic: 'Q/D : courir · Espace/Z : sauter · Clic gauche : croquer le décor · F : lancer la bougie · K : abandonner',
+    castor: 'Q/D : courir · Espace/Z : sauter · Clic gauche : coup de dents · Clic droit (maintenu) : manger · F : lancer la bougie · K : abandonner',
+    gege: 'Q/D : ramper · Espace/Z : sauter · Clic gauche : coup de tête · Clic droit (maintenu) : nager dans le gâteau · F : lancer la bougie · K : abandonner',
+    pompier: 'Q/D : courir · Espace/Z : sauter · Clic gauche : jet d’eau · Clic droit : détremper · F : lancer la bougie · K : abandonner',
+    prout: 'Q/D : courir · Espace/Z : sauter · Clic gauche : coup de cape · Clic droit : propulsion · F : lancer la bougie · K : abandonner',
+    chat: 'Q/D : courir · Espace/Z : sauter · Clic gauche : coup de patte · Clic droit : bondir griffes en avant · F : lancer la bougie · K : abandonner',
+    magicien: 'Q/D : courir · Espace/Z : sauter · Clic gauche : baguette · Clic droit : lancer un sort · F : lancer la bougie · K : abandonner',
   } as Record<string, string>,
   fps: (n: number) => `${n} FPS`,
   browser: {
@@ -41,6 +41,7 @@ export const fr = {
   },
   lobby: {
     title: 'Échauffement',
+    character: 'Ton perso',
     hint: 'Croquez le décor en attendant tout le monde !',
     ready: 'Je suis prêt !',
     notReady: 'Attends, pas encore !',
@@ -61,6 +62,20 @@ export const fr = {
     imposeOnYou: (picker: string) => `Tu es premier : ${picker} va t’imposer un personnage…`,
     imposed: (leader: string, character: string) => `${leader} jouera ${character} !`,
   },
+  stopped: 'L’hôte a arrêté la partie : retour à l’échauffement.',
+  options: {
+    title: 'Options',
+    master: 'Volume général',
+    music: 'Musique',
+    sfx: 'Bruitages',
+    mute: 'Couper le son',
+    shake: 'Secousses de l’écran',
+    keys: 'Échap : options · K : abandonner quand tu es coincé (retour par la cuillère)',
+    stop: 'Arrêter la partie',
+    stopConfirm: 'Arrêter la partie pour tout le monde et revenir à l’échauffement ?',
+    quit: 'Retour au menu',
+    close: 'Reprendre',
+  },
   desync: 'Oups, ton gâteau ne ressemble plus à celui du serveur (désynchronisation).',
   hud: {
     round: (n: number, total: number) => `Manche ${n}/${total}`,
@@ -78,6 +93,8 @@ export const fr = {
     water: ['PSCHHH !', 'SPLASH !', 'FLOTCH !'],
     emerge: ['SURPRISE !', 'COUCOU !', 'TADAM !'],
     lick: ['SLURP…', 'LAP LAP…', 'RONRON…'],
+    pounce: ['MRAOU !', 'FFFT !', 'GRIFFE !'],
+    backfire: ['AÏE !', 'OUILLE !', 'OUPS !'],
   },
   spells: {
     jelly: 'Gélifico !',
@@ -92,6 +109,7 @@ export const fr = {
     round: 'Manche',
     total: 'Total',
     next: (s: number) => `Manche suivante dans ${s} s…`,
+    noCake: 'Il ne reste plus assez de gâteau : manche terminée !',
     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.',
@@ -126,7 +144,7 @@ export const fr = {
       desc: 'Un manchot qui adore les feux d’artifice.',
       attack: 'Pétards qui explosent au contact',
       special: 'Glissade sur le ventre, explosion à l’arrivée',
-      flaw: '1 pétard sur 3 lui explose dessus',
+      flaw: 'De temps en temps, un pétard lui explose dessus',
     },
     gege: {
       name: 'Gégé le Ver',
@@ -139,21 +157,21 @@ export const fr = {
       name: 'Bébé Pompier',
       desc: 'Arrose tout ce qui bouge, et ce qui ne bouge pas.',
       attack: 'Jet d’eau qui repousse',
-      special: 'Détrempe la génoise, qui s’affaisse lentement',
+      special: 'Détrempe la génoise, qui cède sous les pieds, et trempe ceux qu’il arrose (plus lents)',
       flaw: 'Le recul le propulse en arrière',
     },
     prout: {
       name: 'Captain Prout',
       desc: 'Un super-héros propulsé au gaz.',
       attack: 'Coup de cape',
-      special: 'Petites poussées en l’air, qui laissent un nuage toxique',
+      special: '3 petites poussées en l’air, qui laissent un nuage toxique',
       flaw: 'Chaque poussée s’entend',
     },
     chat: {
       name: 'Le Chat',
-      desc: 'Pousse tout ce qui dépasse du bord.',
-      attack: 'Coup de patte',
-      special: 'Pousse blocs, objets et joueurs assommés',
+      desc: 'Griffe tout, et pousse tout ce qui dépasse du bord.',
+      attack: 'Coup de patte qui griffe le décor',
+      special: 'Bond griffes en avant : creuse, éjecte qui il percute, pousse ce qui est devant',
       flaw: 'S’arrête au hasard pour se lécher la patte',
     },
     magicien: {
@@ -163,6 +181,13 @@ export const fr = {
       special: 'Sort au hasard : gelée, échange, lapin géant ou « pfft »',
       flaw: 'Plus il mène, plus ses sorts ratent',
     },
+    random: {
+      name: 'Au hasard',
+      desc: 'Un gourmand tiré au sort, différent à chaque manche.',
+      attack: 'Surprise',
+      special: 'Surprise',
+      flaw: 'Tu ne sais jamais sur qui tu vas tomber',
+    },
     generic: {
       name: 'Le Testeur',
       desc: 'Court, saute, croque. Rien de plus.',

+ 10 - 0
src/renderer/input.ts

@@ -13,17 +13,27 @@ const KEYMAP: Record<string, number> = {
   ArrowDown: Button.Down,
   KeyE: Button.Special,
   KeyF: Button.Use,
+  KeyK: Button.Suicide,
 };
 
 /** Tracks keyboard/mouse state as a button bitmask plus the mouse position in screen pixels. */
 export class InputTracker {
+  /** Buttons to send to the server: none while a menu is open. */
+  get active(): number {
+    return this.paused ? 0 : this.buttons;
+  }
+
   buttons = 0;
+  /** While true (a menu is open), no button reaches the game. */
+  paused = false;
   mouseX = 0;
   mouseY = 0;
 
   constructor(target: HTMLElement) {
     window.addEventListener('keydown', (e) => {
       const b = KEYMAP[e.code];
+      // Typing in a field (nickname, a menu) is not playing.
+      if (e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement) return;
       if (b) {
         this.buttons |= b;
         e.preventDefault();

+ 97 - 22
src/renderer/main.ts

@@ -1,10 +1,10 @@
 import 'pixi.js/unsafe-eval'; // Pixi without eval, required by our Content-Security-Policy
-import { Application, BlurFilter, CanvasSource, Container, Sprite, Texture } from 'pixi.js';
+import { Application, CanvasSource, Container, Sprite, Texture } from 'pixi.js';
 import { Anim, PLAYABLE_CHARACTER_IDS, parseCharacterStats } from '../shared/characters';
-import { DEFAULT_CHARACTER_ID, INTERPOLATION_DELAY_MS, PROTOCOL_VERSION } from '../shared/constants';
+import { DEFAULT_CHARACTER_ID, INTERPOLATION_DELAY_MS, PROTOCOL_VERSION, ROUND_DURATION_S } 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 FxEvent, type PlayerInfo, type PlayerState, type ServerMessage } from '../shared/protocol';
+import { hello, type ChunkInfo, type ChunkState, type FxEvent, type Phase, 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';
@@ -20,7 +20,6 @@ import {
   DREAM_SUPERSAMPLE,
   BACKGROUND_BLUR,
   MAX_RESOLUTION,
-  POISON_BLUR,
   INPUT_SEND_HZ,
   LANDING_DUST_MIN_VY,
   MERGE_SHAKE,
@@ -33,10 +32,11 @@ import {
   SHAKE_PER_PIXEL,
   WHISK_SHAKE,
 } from './config';
+import { GameAudio, type SoundName } from './audio/Audio';
 import { ChunkView } from './entities/ChunkView';
 import { PlayerView } from './entities/PlayerView';
 import { CherryView, CloudsView, CustardView, ItemsView, SpoonsView } from './entities/RoundViews';
-import { makeDreamFilter, SugarDust } from './fx/Dream';
+import { makeDreamFilter, setDreamPoison, SugarDust } from './fx/Dream';
 import { Particles } from './fx/Particles';
 import { Popups } from './fx/Popups';
 import { fr } from './i18n/fr';
@@ -48,6 +48,8 @@ import { showBrowser } from './ui/browser';
 import { ResultsScreen, RoundHud } from './ui/hud';
 import { LobbyPanel } from './ui/lobbyPanel';
 import { showMenu, type MenuChoice } from './ui/menu';
+import { OptionsScreen } from './ui/options';
+import { settings } from './settings';
 
 const PORTRAIT_RADIUS = 34;
 const EATING_POPUP_RANGE = 120;
@@ -106,7 +108,7 @@ function makeVignette(): Sprite {
 function makePortraits(app: Application): Map<string, HTMLCanvasElement> {
   const out = new Map<string, HTMLCanvasElement>();
   PLAYABLE_CHARACTER_IDS.forEach((characterId, i) => {
-    const view = new PlayerView({ id: i + 1, name: '', characterId, radius: PORTRAIT_RADIUS });
+    const view = new PlayerView({ id: i + 1, name: '', characterId, radius: PORTRAIT_RADIUS, variant: 0 });
     view.drawIdle();
     out.set(characterId, app.renderer.extract.canvas({ target: view.container, resolution: 3 }) as HTMLCanvasElement);
     view.destroy();
@@ -115,11 +117,11 @@ function makePortraits(app: Application): Map<string, HTMLCanvasElement> {
 }
 
 /** Title menu then game browser, until the player picks a game to host or join. */
-async function chooseGame(app: Application): Promise<{ choice: MenuChoice; address: string; port: number }> {
+async function chooseGame(app: Application, options: OptionsScreen): Promise<{ choice: MenuChoice; address: string; port: number }> {
   const portraits = makePortraits(app);
   const stats = new Map((await window.assets.characters()).map((raw) => parseCharacterStats(raw)).map((st) => [st.id, st]));
   for (;;) {
-    const choice = await showMenu(PLAYABLE_CHARACTER_IDS, DEFAULT_CHARACTER_ID, portraits, stats);
+    const choice = await showMenu(PLAYABLE_CHARACTER_IDS, DEFAULT_CHARACTER_ID, portraits, stats, options);
     const target = await showBrowser();
     if (target.mode === 'back') continue;
     if (target.mode === 'join') return { choice, address: target.address, port: target.port };
@@ -142,15 +144,19 @@ async function start(): Promise<void> {
   });
   host.appendChild(app.canvas);
 
-  const { choice, address, port } = await chooseGame(app);
+  const audio = new GameAudio();
+  const options = new OptionsScreen();
+  const { choice, address, port } = await chooseGame(app, options);
   helpEl.textContent = fr.help[choice.characterId] ?? '';
   setStatus(fr.connecting);
 
   const camera = new Container();
   const worldRoot = new Container();
   camera.addChild(worldRoot);
-  // Dreamy look over the whole world (the poison blur lives on worldRoot).
-  camera.filters = [makeDreamFilter(app.renderer.resolution * DREAM_SUPERSAMPLE)];
+  // Dreamy look over the whole world; it also blurs the sight of a poisoned player.
+  const dream = makeDreamFilter(app.renderer.resolution * DREAM_SUPERSAMPLE);
+  camera.filters = [dream];
+  let poison = 0;
   const dust = new SugarDust();
   const vignette = makeVignette();
   app.stage.addChild(camera, vignette);
@@ -163,8 +169,6 @@ async function start(): Promise<void> {
   const custardView = new CustardView();
   const spoonsView = new SpoonsView();
   const cloudsView = new CloudsView();
-  // Toxic cloud: the whole world goes blurry for the poisoned local player.
-  const poisonBlur = new BlurFilter({ strength: 0, quality: 2, resolution: 0.5 });
   // Map-independent layers always sit above the map ones (background, terrain).
   // The custard covers players who sink into it; the spoon flicks out of it.
   worldRoot.addChild(
@@ -274,6 +278,12 @@ async function start(): Promise<void> {
         if (m) particles.burst(op.x, op.y, Math.ceil((count / 100) * PARTICLES_PER_100_PX), parseHexColor(m.color));
       }
       shake = Math.min(SHAKE_MAX, shake + total * SHAKE_PER_PIXEL);
+      // The sound of the material that was eaten most (materials.json "sfx").
+      let loudest = 0;
+      let loudestCount = 0;
+      for (const [mat, count] of removedByMaterial) if (count > loudestCount) [loudest, loudestCount] = [mat, count];
+      const sfx = world.materials.byId[loudest]?.sfx;
+      if (sfx && loudestCount > 0) audio.play(sfx as SoundName, op.x, op.y, Math.min(1, 0.35 + loudestCount / 400));
       if (total >= POPUP_MIN_PIXELS && popupCooldown <= 0) {
         popupCooldown = POPUP_COOLDOWN;
         popups.spawn(op.x, op.y - op.r, nearEater(op.x, op.y) ? fr.onomatopoeia.munch : fr.onomatopoeia.crunch);
@@ -284,14 +294,20 @@ async function start(): Promise<void> {
     const cells = rleDecode(ev.kind === 'detach' ? ev.mask : ev.patch, ev.w * ev.h);
     if (ev.kind === 'detach') {
       if (!live) return;
-      if (ev.debris) particles.burst(ev.x + ev.w / 2, ev.y + ev.h / 2, Math.max(3, Math.ceil(cells.length / 20)), mainColor(cells), 200);
-      else addChunk(ev.id, cells, ev.w, ev.h, { id: ev.id, x: ev.x, y: ev.y, angle: 0 });
+      if (ev.debris) {
+        particles.burst(ev.x + ev.w / 2, ev.y + ev.h / 2, Math.max(3, Math.ceil(cells.length / 20)), mainColor(cells), 200);
+        audio.play('puff', ev.x + ev.w / 2, ev.y + ev.h / 2, 0.5);
+      } else {
+        addChunk(ev.id, cells, ev.w, ev.h, { id: ev.id, x: ev.x, y: ev.y, angle: 0 });
+        audio.play('crrrac', ev.x + ev.w / 2, ev.y + ev.h / 2);
+      }
       return;
     }
     // Merge: the sprite becomes terrain again.
     removeChunk(ev.id);
     if (!live) return;
     particles.puff(ev.x + ev.w / 2, ev.y + ev.h, 12);
+    audio.play('thud', ev.x + ev.w / 2, ev.y + ev.h);
     shake = Math.min(SHAKE_MAX, shake + MERGE_SHAKE);
     popups.spawn(ev.x + ev.w / 2, ev.y, fr.onomatopoeia.thud, 30);
   };
@@ -333,7 +349,14 @@ async function start(): Promise<void> {
   /** A character's signature move: everyone sees (and soon hears) it. */
   const onCue = (ev: Extract<FxEvent, { kind: 'cue' }>): void => {
     const size = states.get(ev.id)?.size ?? 24;
+    audio.play(ev.cue.startsWith('spell-') ? 'spell' : (ev.cue as SoundName), ev.x, ev.y);
     switch (ev.cue) {
+      case 'pounce':
+        popups.spawn(ev.x, ev.y - size - 16, fr.fxWords.pounce, 30);
+        break;
+      case 'backfire':
+        popups.spawn(ev.x, ev.y - size - 20, fr.fxWords.backfire, 34);
+        break;
       case 'prout':
         particles.puff(ev.x, ev.y + size, 8, 0x9be15d);
         popups.spawn(ev.x, ev.y + size + 10, fr.fxWords.prout, 34);
@@ -361,13 +384,16 @@ async function start(): Promise<void> {
   const onFx = (ev: FxEvent): void => {
     switch (ev.kind) {
       case 'splash':
+        audio.play('splash', ev.x, ev.y, ev.id ? 1 : 0.5);
         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':
+        audio.play('spoon', ev.x, ev.y);
         spoonsView.spawn(ev.x, ev.y);
         break;
       case 'explosion':
+        audio.play('explosion', ev.x, ev.y);
         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);
@@ -376,16 +402,19 @@ async function start(): Promise<void> {
         freezeUntil = performance.now() + FREEZE_FRAME_MS;
         break;
       case 'pickup':
+        audio.play(ev.item === 'cherry' ? 'cherry' : 'pickup', ev.x, ev.y);
         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':
+        audio.play('cherryDrop', ev.x, ev.y);
         popups.spawn(ev.x, ev.y - 40, fr.fxWords.drop, 32);
         break;
       case 'cue':
         onCue(ev);
         break;
       case 'whisk':
+        audio.play('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);
@@ -399,8 +428,23 @@ async function start(): Promise<void> {
     () => localId,
     (ready) => conn.send({ t: 'ready', ready }),
     (rotation) => conn.send({ t: 'rotation', rotation }),
+    PLAYABLE_CHARACTER_IDS,
+    (characterId) => conn.send({ t: 'character', characterId }),
   );
   let mapNames = new Map<string, string>();
+  let phase: Phase = 'lobby';
+  let hostId = 0;
+  let countdown: number | null = null;
+  options.setGameActions({
+    canStop: () => hostId === localId && phase !== 'lobby',
+    onStop: () => conn.send({ t: 'stopMatch' }),
+    // Back to the title screen: a fresh page (the hosted server keeps running for the others).
+    onQuit: () => window.location.reload(),
+  });
+  /** Help line for the character actually played (it may be drawn at random, or imposed). */
+  const updateHelp = (): void => {
+    helpEl.textContent = fr.help[infos.get(localId)?.characterId ?? choice.characterId] ?? '';
+  };
 
   const onMessage = (msg: ServerMessage): void => {
     switch (msg.t) {
@@ -409,18 +453,19 @@ async function start(): Promise<void> {
         round = msg.round;
         rounds = msg.rounds;
         infos = new Map(msg.players.map((p) => [p.id, p]));
+        updateHelp();
         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]));
-        // The character may differ from the one chosen in the menu (imposed for this round).
-        const mine = infos.get(localId)?.characterId ?? choice.characterId;
-        helpEl.textContent = fr.help[mine] ?? '';
+        // The character may differ from the one chosen in the menu (imposed, or drawn at random).
+        updateHelp();
         results.hide();
         resetWorld(msg.mapId, [], []);
         if (round === 0) break; // back to the warm-up: no fanfare
+        audio.play('go');
         setStatus(fr.hud.round(round, rounds));
         setTimeout(() => {
           if (statusEl.textContent === fr.hud.round(round, rounds)) setStatus('');
@@ -428,15 +473,31 @@ async function start(): Promise<void> {
         break;
       }
       case 'roundEnd':
+        audio.play('roundEnd');
         results.showRound(msg, localId);
         break;
       case 'matchEnd':
+        audio.play('fanfare');
         results.showMatch(msg, localId);
         break;
+      case 'nextIn':
+        results.setNextIn(msg.seconds);
+        break;
+      case 'matchStopped':
+        results.hide();
+        setStatus(fr.stopped);
+        setTimeout(() => {
+          if (statusEl.textContent === fr.stopped) setStatus('');
+        }, 2500);
+        break;
       case 'fx':
         onFx(msg.ev);
         break;
       case 'lobby':
+        if (msg.countdown !== null && msg.countdown !== countdown) audio.play('beep');
+        countdown = msg.countdown;
+        phase = msg.phase;
+        hostId = msg.hostId;
         lobbyPanel.update(msg);
         mapNames = new Map(msg.maps.map((m) => [m.id, m.name]));
         break;
@@ -457,6 +518,7 @@ async function start(): Promise<void> {
         break;
       case 'playerJoined':
         infos.set(msg.player.id, msg.player);
+        if (msg.player.id === localId) updateHelp(); // changed character during the warm-up
         break;
       case 'playerLeft':
         infos.delete(msg.id);
@@ -474,6 +536,7 @@ async function start(): Promise<void> {
         removeChunk(msg.id);
         break;
       case 'crushed':
+        audio.play('crush', msg.x, msg.y);
         popups.spawn(msg.x, msg.y, fr.onomatopoeia.crush, 46);
         particles.burst(msg.x, msg.y, 24, PLAYER_SPLAT_COLOR, 500);
         shake = Math.min(SHAKE_MAX, shake + CRUSH_SHAKE);
@@ -502,7 +565,8 @@ async function start(): Promise<void> {
 
   setInterval(() => {
     const [aimX, aimY] = screenToWorld(input.mouseX, input.mouseY);
-    conn.send({ t: 'input', buttons: input.buttons, aimX: Math.round(aimX), aimY: Math.round(aimY) });
+    input.paused = options.isOpen;
+    conn.send({ t: 'input', buttons: input.active, aimX: Math.round(aimX), aimY: Math.round(aimY) });
   }, 1000 / INPUT_SEND_HZ);
 
   let camX = 0;
@@ -554,12 +618,17 @@ async function start(): Promise<void> {
         playersLayer.addChild(view.container);
       }
       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);
+      if (ev.landed && ev.landingSpeed > LANDING_DUST_MIN_VY) {
+        particles.puff(state.x, state.y + state.size, 8);
+        audio.play('land', state.x, state.y, Math.min(1, ev.landingSpeed / 1200));
+      }
+      if (ev.jumped) audio.play('jump', state.x, state.y, id === localId ? 0.8 : 0.4);
       // 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) {
+        audio.play('hit', state.x, state.y);
         popups.spawn(state.x, state.y - state.size, fr.onomatopoeia.hit, 40);
         particles.burst(state.x, state.y, 10, 0xffe14d, 300);
         shake = Math.min(SHAKE_MAX, shake + HIT_SHAKE);
@@ -572,8 +641,9 @@ async function start(): Promise<void> {
     camera.scale.set(scale);
     const me = states.get(localId);
     const poisoned = me !== undefined && (me.anim & Anim.Poisoned) !== 0;
-    poisonBlur.strength += ((poisoned ? POISON_BLUR : 0) - poisonBlur.strength) * Math.min(1, 6 * dt);
-    worldRoot.filters = poisonBlur.strength > 0.2 ? [poisonBlur] : [];
+    // Toxic cloud: the world goes blurry for the poisoned local player (fades in and out).
+    poison += ((poisoned ? 1 : 0) - poison) * Math.min(1, 6 * dt);
+    setDreamPoison(dream, poison < 0.01 ? 0 : poison);
     const viewW = app.screen.width / scale;
     const viewH = app.screen.height / scale;
     if (me) {
@@ -588,6 +658,11 @@ async function start(): Promise<void> {
     const cx = viewW >= width ? (width - viewW) / 2 : Math.max(0, Math.min(width - viewW, camX));
     const cy = viewH >= height ? (height - viewH) / 2 : Math.max(0, Math.min(height - viewH, camY));
     shake = Math.max(0, shake - SHAKE_DECAY * dt);
+    if (!settings().shake) shake = 0;
+    audio.setListener(cx + viewW / 2, cy + viewH / 2);
+    // The tune speeds up as the round goes, and races during the final rush.
+    const timeLeft = sample.round?.timeLeft ?? null;
+    audio.setMusic(true, timeLeft === null ? 0 : sample.round?.finalRush ? 1 : 0.4 * (1 - timeLeft / ROUND_DURATION_S));
     worldRoot.position.set(Math.round(-cx + (Math.random() - 0.5) * shake * 2), Math.round(-cy + (Math.random() - 0.5) * shake * 2));
   });
 }

+ 45 - 0
src/renderer/settings.ts

@@ -0,0 +1,45 @@
+// Player preferences, kept in localStorage (per machine). Purely client-side.
+
+export interface Settings {
+  /** 0..1 */
+  master: number;
+  music: number;
+  sfx: number;
+  muted: boolean;
+  /** Screen shake on impacts (some players get dizzy). */
+  shake: boolean;
+}
+
+const KEY = 'gouter.settings';
+const DEFAULTS: Settings = { master: 0.8, music: 0.5, sfx: 0.9, muted: false, shake: true };
+
+function load(): Settings {
+  try {
+    const raw = localStorage.getItem(KEY);
+    return raw ? { ...DEFAULTS, ...(JSON.parse(raw) as Partial<Settings>) } : { ...DEFAULTS };
+  } catch {
+    return { ...DEFAULTS };
+  }
+}
+
+const current = load();
+const listeners = new Set<(s: Settings) => void>();
+
+export function settings(): Readonly<Settings> {
+  return current;
+}
+
+export function updateSettings(change: Partial<Settings>): void {
+  Object.assign(current, change);
+  try {
+    localStorage.setItem(KEY, JSON.stringify(current));
+  } catch {
+    // Storage unavailable: the settings last until the game is closed.
+  }
+  for (const fn of listeners) fn(current);
+}
+
+export function onSettings(fn: (s: Settings) => void): void {
+  listeners.add(fn);
+  fn(current);
+}

+ 111 - 0
src/renderer/style.css

@@ -740,3 +740,114 @@ input.invalid {
   color: var(--pink-dark);
   font-weight: bold;
 }
+
+/* ---------- Options ---------- */
+
+#options {
+  position: fixed;
+  inset: 0;
+  display: grid;
+  place-items: center;
+  background: rgba(59, 36, 20, 0.35);
+  z-index: 20;
+}
+
+#options[hidden] {
+  display: none;
+}
+
+.options-card {
+  width: min(460px, calc(100vw - 32px));
+  text-align: left;
+}
+
+.options-card h1 {
+  text-align: center;
+  margin-bottom: 12px;
+}
+
+.option-row {
+  display: grid;
+  grid-template-columns: 150px 1fr;
+  align-items: center;
+  gap: 12px;
+  margin: 8px 0;
+  font-weight: bold;
+}
+
+.option-row.check {
+  grid-template-columns: auto 1fr;
+}
+
+.option-row input[type='range'] {
+  accent-color: var(--pink);
+}
+
+.option-row input[type='checkbox'] {
+  width: 20px;
+  height: 20px;
+  accent-color: var(--pink);
+}
+
+.option-keys {
+  font-size: 13px;
+  background: #fff;
+  border: 2px dashed var(--ink);
+  border-radius: 10px;
+  padding: 6px 10px;
+}
+
+.option-buttons {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: flex-end;
+  gap: 10px;
+  margin-top: 14px;
+}
+
+#options-button {
+  position: fixed;
+  right: 16px;
+  bottom: 12px;
+  width: 42px;
+  height: 42px;
+  font-size: 24px;
+  line-height: 1;
+  background: var(--paper);
+  color: var(--ink);
+  border: 3px solid var(--ink);
+  border-radius: 50%;
+  box-shadow: 3px 3px 0 rgba(59, 36, 20, 0.4);
+  cursor: pointer;
+  z-index: 21;
+}
+
+#options-button:hover {
+  background: #fff;
+}
+
+#lobby-panel .character-pick {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  margin: 6px 0 8px;
+  font-size: 13px;
+  font-weight: bold;
+  text-align: left;
+}
+
+#lobby-panel select {
+  font: inherit;
+  font-weight: normal;
+}
+
+.menu-buttons {
+  display: flex;
+  justify-content: center;
+  align-items: flex-end;
+  gap: 16px;
+}
+
+.menu-buttons #menu-options {
+  margin-bottom: 4px;
+}

+ 15 - 6
src/renderer/ui/hud.ts

@@ -75,6 +75,8 @@ export class ResultsScreen {
   private readonly root = el('div');
   private timer: ReturnType<typeof setInterval> | null = null;
   private pickBox: HTMLDivElement | null = null;
+  /** Seconds left on the results screen, shown in the footer. */
+  private left = 0;
 
   constructor(
     private readonly onPick: (mapId: string) => void,
@@ -93,7 +95,7 @@ export class ResultsScreen {
   }
 
   showRound(msg: RoundEndMessage, localId: number): void {
-    this.show(fr.results.roundTitle(msg.round, msg.rounds), msg.results, localId, null, msg.nextIn, fr.results.next);
+    this.show(fr.results.roundTitle(msg.round, msg.rounds), msg.results, localId, null, msg.nextIn, fr.results.next, msg.reason);
     // Catch-up (readme §4): the last-placed player picks the next map and imposes a character on the leader.
     if (msg.picker === null || msg.choices.length === 0) return;
     const box = el('div', 'map-pick');
@@ -139,7 +141,12 @@ export class ResultsScreen {
   }
 
   showMatch(msg: MatchEndMessage, localId: number): void {
-    this.show(fr.results.matchTitle, msg.results, localId, msg, msg.lobbyIn, fr.results.lobby);
+    this.show(fr.results.matchTitle, msg.results, localId, msg, msg.lobbyIn, fr.results.lobby, msg.reason);
+  }
+
+  /** The server shortened the countdown (every pick is done). */
+  setNextIn(seconds: number): void {
+    this.left = Math.min(this.left, Math.ceil(seconds));
   }
 
   private show(
@@ -149,9 +156,11 @@ export class ResultsScreen {
     match: MatchEndMessage | null,
     seconds: number,
     footer: (s: number) => string,
+    reason: 'time' | 'noCake',
   ): void {
     const card = el('div', 'menu-card');
     card.appendChild(el('h1', 'small', title));
+    if (reason === 'noCake') card.appendChild(el('p', 'winner', fr.results.noCake));
     if (match && results[0]) card.appendChild(el('p', 'winner', fr.results.winner(results[0].name)));
 
     const table = el('table');
@@ -185,12 +194,12 @@ export class ResultsScreen {
     this.root.replaceChildren(card);
     this.root.hidden = false;
 
-    let left = Math.ceil(seconds);
-    foot.textContent = footer(left);
+    this.left = Math.ceil(seconds);
+    foot.textContent = footer(this.left);
     if (this.timer) clearInterval(this.timer);
     this.timer = setInterval(() => {
-      left = Math.max(0, left - 1);
-      foot.textContent = footer(left);
+      this.left = Math.max(0, this.left - 1);
+      foot.textContent = footer(this.left);
     }, 1000);
   }
 }

+ 15 - 3
src/renderer/ui/lobbyPanel.ts

@@ -1,4 +1,4 @@
-import type { LobbyPlayer, MapSummary, Phase, Rotation } from '../../shared/protocol';
+import { RANDOM_CHARACTER, type LobbyPlayer, type MapSummary, type Phase, type Rotation } from '../../shared/protocol';
 import { fr } from '../i18n/fr';
 
 export type LobbyMessage = { phase: Phase; players: LobbyPlayer[]; countdown: number | null; hostId: number; rotation: Rotation; maps: MapSummary[] };
@@ -15,6 +15,7 @@ export class LobbyPanel {
   private readonly status: HTMLParagraphElement;
   private readonly button: HTMLButtonElement;
   private readonly rotationBox: HTMLDivElement;
+  private readonly characterSelect: HTMLSelectElement;
   private ready = false;
   /** Last rotation UI state rendered, to avoid rebuilding (and losing focus) on every lobby update. */
   private rotationKey = '';
@@ -23,14 +24,23 @@ export class LobbyPanel {
     private readonly localId: () => number,
     onReady: (ready: boolean) => void,
     private readonly onRotation: (rotation: Rotation) => void,
+    characterIds: string[],
+    onCharacter: (characterId: string) => void,
   ) {
     this.root = document.createElement('div');
     this.root.id = 'lobby-panel';
-    this.root.innerHTML = `<h3>${fr.lobby.title}</h3><p class="hint">${fr.lobby.hint}</p><ul></ul><div class="rotation"></div><p class="status"></p><button class="primary"></button>`;
+    this.root.innerHTML = `<h3>${fr.lobby.title}</h3><p class="hint">${fr.lobby.hint}</p><ul></ul><label class="character-pick">${fr.lobby.character}<select></select></label><div class="rotation"></div><p class="status"></p><button class="primary"></button>`;
     this.list = this.root.querySelector('ul')!;
     this.status = this.root.querySelector('.status')!;
     this.button = this.root.querySelector('button.primary')!;
     this.rotationBox = this.root.querySelector('.rotation')!;
+    // Changing character during the warm-up (the match keeps it).
+    this.characterSelect = this.root.querySelector('select')!;
+    for (const id of [...characterIds, RANDOM_CHARACTER]) this.characterSelect.appendChild(new Option(fr.characters[id]?.name ?? id, id));
+    this.characterSelect.addEventListener('change', () => {
+      onCharacter(this.characterSelect.value);
+      this.characterSelect.blur();
+    });
     this.button.addEventListener('click', () => {
       onReady(!this.ready);
       this.button.blur(); // keep Space for jumping, not for re-clicking
@@ -43,7 +53,9 @@ export class LobbyPanel {
     const { phase, players, countdown } = msg;
     this.root.hidden = phase !== 'lobby';
     if (phase !== 'lobby') return;
-    this.ready = players.find((p) => p.id === this.localId())?.ready ?? false;
+    const me = players.find((p) => p.id === this.localId());
+    this.ready = me?.ready ?? false;
+    if (me && document.activeElement !== this.characterSelect) this.characterSelect.value = me.characterId;
     this.button.textContent = this.ready ? fr.lobby.notReady : fr.lobby.ready;
     this.button.classList.toggle('secondary', this.ready);
     this.list.replaceChildren(

+ 38 - 2
src/renderer/ui/menu.ts

@@ -1,5 +1,7 @@
 import type { CharacterStats } from '../../shared/characters';
+import { RANDOM_CHARACTER } from '../../shared/protocol';
 import { fr } from '../i18n/fr';
+import type { OptionsScreen } from './options';
 
 export interface MenuChoice {
   name: string;
@@ -27,6 +29,33 @@ function save(key: string, value: string): void {
 
 const PIPS = 5;
 
+/** Portrait of the "random character" card: a big question mark on a candy disc. */
+export function randomPortrait(): HTMLCanvasElement {
+  const canvas = document.createElement('canvas');
+  canvas.width = 210;
+  canvas.height = 210;
+  const ctx = canvas.getContext('2d')!;
+  ctx.lineWidth = 9;
+  ctx.strokeStyle = '#3b2414';
+  ctx.fillStyle = '#ffd23f';
+  ctx.beginPath();
+  ctx.arc(105, 110, 82, 0, Math.PI * 2);
+  ctx.fill();
+  ctx.stroke();
+  ctx.fillStyle = 'rgba(255, 255, 255, 0.45)';
+  ctx.beginPath();
+  ctx.ellipse(78, 72, 30, 14, -0.5, 0, Math.PI * 2);
+  ctx.fill();
+  ctx.font = 'bold 120px "Comic Sans MS", sans-serif';
+  ctx.textAlign = 'center';
+  ctx.textBaseline = 'middle';
+  ctx.lineWidth = 8;
+  ctx.strokeText('?', 105, 118);
+  ctx.fillStyle = '#ff6f91';
+  ctx.fillText('?', 105, 118);
+  return canvas;
+}
+
 /** A row of PIPS dots, `value` relative to the best character for that stat. */
 function statBar(label: string, value: number, best: number): HTMLDivElement {
   const row = document.createElement('div');
@@ -54,6 +83,7 @@ export function showMenu(
   defaultId: string,
   previews: Map<string, HTMLCanvasElement>,
   stats: Map<string, CharacterStats>,
+  options: OptionsScreen,
 ): Promise<MenuChoice> {
   const root = document.createElement('div');
   root.id = 'menu';
@@ -67,13 +97,18 @@ export function showMenu(
       <h2>${fr.chooseCharacter}</h2>
       <div class="characters"></div>
       <div class="sheet"></div>
-      <button id="menu-play">${fr.play}</button>
+      <div class="menu-buttons">
+        <button id="menu-options" type="button" class="secondary">${fr.options.title}</button>
+        <button id="menu-play">${fr.play}</button>
+      </div>
     </div>`;
   document.body.appendChild(root);
 
   const nameInput = root.querySelector<HTMLInputElement>('#menu-name')!;
   nameInput.value = load(NAME_KEY) ?? fr.defaultName;
   let selected = load(CHARACTER_KEY) ?? defaultId;
+  // The last card draws a character at random for every round.
+  characterIds = [...characterIds, RANDOM_CHARACTER];
   if (!characterIds.includes(selected)) selected = defaultId;
 
   const list = root.querySelector<HTMLDivElement>('.characters')!;
@@ -83,7 +118,7 @@ export function showMenu(
     const card = document.createElement('button');
     card.className = 'character';
     card.type = 'button';
-    const preview = previews.get(id);
+    const preview = id === RANDOM_CHARACTER ? randomPortrait() : previews.get(id);
     if (preview) {
       preview.className = 'portrait';
       card.appendChild(preview);
@@ -151,6 +186,7 @@ export function showMenu(
     resolve({ name, characterId: selected });
   }
   root.querySelector('#menu-play')!.addEventListener('click', play);
+  root.querySelector('#menu-options')!.addEventListener('click', () => options.open());
   nameInput.addEventListener('keydown', (e) => {
     if (e.key === 'Enter') play();
   });

+ 132 - 0
src/renderer/ui/options.ts

@@ -0,0 +1,132 @@
+import { fr } from '../i18n/fr';
+import { settings, updateSettings, type Settings } from '../settings';
+
+/** What the options screen can do during a game (absent on the title screen). */
+export interface GameActions {
+  /** True when the local player is the host and a match is running: they may stop it. */
+  canStop: () => boolean;
+  onStop: () => void;
+  onQuit: () => void;
+}
+
+/**
+ * Options overlay: volumes, mute, screen shake, a reminder of the keys, and during a game
+ * the host's "stop the match" and "back to the menu". Opened with Escape or the ⚙ button.
+ */
+export class OptionsScreen {
+  private readonly root = document.createElement('div');
+  private readonly gear = document.createElement('button');
+  private actions: GameActions | null = null;
+
+  constructor() {
+    this.root.id = 'options';
+    this.root.hidden = true;
+    this.root.addEventListener('pointerdown', (e) => {
+      if (e.target === this.root) this.close(); // click outside the card
+    });
+    this.gear.id = 'options-button';
+    this.gear.type = 'button';
+    this.gear.title = fr.options.title;
+    this.gear.textContent = '⚙';
+    this.gear.addEventListener('click', () => {
+      this.toggle();
+      this.gear.blur();
+    });
+    document.body.append(this.root, this.gear);
+    window.addEventListener('keydown', (e) => {
+      if (e.code !== 'Escape') return;
+      e.preventDefault();
+      this.toggle();
+    });
+  }
+
+  get isOpen(): boolean {
+    return !this.root.hidden;
+  }
+
+  /** Enables the in-game buttons. */
+  setGameActions(actions: GameActions): void {
+    this.actions = actions;
+  }
+
+  toggle(): void {
+    if (this.isOpen) this.close();
+    else this.open();
+  }
+
+  open(): void {
+    this.render();
+    this.root.hidden = false;
+  }
+
+  close(): void {
+    this.root.hidden = true;
+  }
+
+  private render(): void {
+    const s = settings();
+    const card = document.createElement('div');
+    card.className = 'menu-card options-card';
+    const title = document.createElement('h1');
+    title.className = 'small';
+    title.textContent = fr.options.title;
+    card.appendChild(title);
+
+    const slider = (label: string, key: 'master' | 'music' | 'sfx'): HTMLLabelElement => {
+      const row = document.createElement('label');
+      row.className = 'option-row';
+      const input = document.createElement('input');
+      input.type = 'range';
+      input.min = '0';
+      input.max = '100';
+      input.value = String(Math.round(s[key] * 100));
+      input.addEventListener('input', () => updateSettings({ [key]: Number(input.value) / 100 } as Partial<Settings>));
+      row.append(label, input);
+      return row;
+    };
+    const check = (label: string, key: 'muted' | 'shake'): HTMLLabelElement => {
+      const row = document.createElement('label');
+      row.className = 'option-row check';
+      const input = document.createElement('input');
+      input.type = 'checkbox';
+      input.checked = s[key];
+      input.addEventListener('change', () => updateSettings({ [key]: input.checked } as Partial<Settings>));
+      row.append(input, label);
+      return row;
+    };
+    card.append(
+      slider(fr.options.master, 'master'),
+      slider(fr.options.music, 'music'),
+      slider(fr.options.sfx, 'sfx'),
+      check(fr.options.mute, 'muted'),
+      check(fr.options.shake, 'shake'),
+    );
+    const keys = document.createElement('p');
+    keys.className = 'option-keys';
+    keys.textContent = fr.options.keys;
+    card.appendChild(keys);
+
+    const buttons = document.createElement('div');
+    buttons.className = 'option-buttons';
+    const button = (label: string, cls: string, onClick: () => void): void => {
+      const b = document.createElement('button');
+      b.type = 'button';
+      b.className = cls;
+      b.textContent = label;
+      b.addEventListener('click', onClick);
+      buttons.appendChild(b);
+    };
+    const actions = this.actions;
+    if (actions?.canStop()) {
+      button(fr.options.stop, 'secondary', () => {
+        if (!window.confirm(fr.options.stopConfirm)) return;
+        actions.onStop();
+        this.close();
+      });
+    }
+    if (actions) button(fr.options.quit, 'secondary', () => actions.onQuit());
+    button(fr.options.close, 'primary', () => this.close());
+    card.appendChild(buttons);
+    this.root.replaceChildren(card);
+  }
+}

+ 214 - 22
src/server/game.ts

@@ -17,6 +17,7 @@ import {
   CANDLE_KNOCKBACK,
   CANDLE_STUN_S,
   CANDLE_THROW_SPEED,
+  CAKE_CHECK_INTERVAL_S,
   CHERRY_DROP_SPEED,
   CHERRY_POINTS_PER_S,
   CHERRY_RADIUS,
@@ -42,7 +43,9 @@ import {
   JUMP_BUFFER_TIME,
   JUMP_CUT,
   MATERIAL_SOAKED,
+  MATERIAL_PLATE,
   MATERIAL_WOOL,
+  MIN_CAKE_PIXELS,
   MIN_CHUNK_PIXELS,
   OUT_OF_MAP_MARGIN,
   PIXELS_PER_METER,
@@ -53,11 +56,20 @@ import {
   RESPAWN_DELAY_S,
   SMARTIES_DURATION_S,
   SMARTIES_SPEED_MULT,
+  SOAKED_JUMP_MULT,
+  SOAKED_S,
+  SOAKED_SPEED_MULT,
   SOAK_SAG_INTERVAL_S,
   SOAK_SAG_POWER,
   SOAK_SAG_RADIUS,
   SOAK_SAG_TIME_S,
+  SPOON_APEX_PX,
   SPOON_LAUNCH_SPEED,
+  SPOON_LAUNCH_TRIES,
+  SPOON_MAX_VX,
+  SPOON_TARGET_TRIES,
+  STUN_GUARD_KNOCKBACK,
+  STUN_GUARD_S,
   TICK_DT,
   WHISK_CRACK_POWER,
   WHISK_CRACK_RADIUS,
@@ -89,8 +101,12 @@ const JELLY = 4;
 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;
+/** Cap on the spoon's sideways throw when it throws blind (px/s). */
+const SPOON_BLIND_MAX_VX = 500;
+/** Columns scanned for landing spots (px between candidates). */
+const SPOON_SPOT_STEP = 12;
+/** A landing spot is flat enough when the ground under the body stays within this height (px). */
+const SPOON_SPOT_FLATNESS = 10;
 /** Free space required on each side of the body along the launch column (px). */
 const SPOON_CLEARANCE = 8;
 
@@ -110,6 +126,8 @@ interface ServerPlayer {
   attackCooldown: number;
   specialCooldown: number;
   stun: number;
+  /** Seconds before a new stun can land (a stun, then STUN_GUARD_S of immunity). */
+  stunLock: number;
   /** Seconds before the spoon sends the player back; 0 = alive. */
   dead: number;
   /** Smarties boost time left. */
@@ -119,6 +137,10 @@ interface ServerPlayer {
   /** Toxic cloud: seconds of poisoning left, and time to the next cough. */
   poison: number;
   cough: number;
+  /** Drenched by Bébé Pompier: seconds left. */
+  soaked: number;
+  /** Raw buttons of the previous tick (the suicide key works even when stunned). */
+  prevRaw: number;
   item: ItemKind | null;
   /** Seconds holding the cherry this round. */
   score: number;
@@ -244,6 +266,10 @@ export class Game {
   private clouds: { x: number; y: number; r: number; life: number; maxLife: number; owner: number }[] = [];
   /** Recently soaked areas, which sag for a while (Bébé Pompier). */
   private soakZones: { x: number; y: number; r: number; time: number; next: number }[] = [];
+  /** Seconds before the next count of the cake left. */
+  private cakeCheck = CAKE_CHECK_INTERVAL_S;
+  /** Set when so little cake is left that the round ends early. */
+  private cakeGone = false;
 
   constructor(opts: GameOptions) {
     this.map = opts.map;
@@ -285,7 +311,12 @@ export class Game {
 
   /** True once the round timer has run out. */
   get roundOver(): boolean {
-    return this.rules !== null && this.timeLeft <= 0;
+    return this.rules !== null && (this.timeLeft <= 0 || this.cakeGone);
+  }
+
+  /** Why the round is over: the timer, or there is (almost) no cake left to stand on. */
+  get endReason(): 'time' | 'noCake' {
+    return this.cakeGone ? 'noCake' : 'time';
   }
 
   get custardLevel(): number {
@@ -314,7 +345,11 @@ export class Game {
     const stats = this.loadCharacter(characterId);
     this.nextPlayerId = Math.max(this.nextPlayerId, id + 1);
     const body = this.world.createBody({ type: 'dynamic', fixedRotation: true, bullet: true });
-    const info: PlayerInfo = { id, name, characterId, radius: stats.size };
+    // Players on the same character get different colors: the lowest variant not taken yet.
+    const taken = new Set([...this.players.values()].filter((o) => o.info.characterId === characterId).map((o) => o.info.variant));
+    let variant = 0;
+    while (taken.has(variant)) variant++;
+    const info: PlayerInfo = { id, name, characterId, radius: stats.size, variant };
     const player: ServerPlayer = {
       info,
       character: createCharacter(stats),
@@ -330,11 +365,14 @@ export class Game {
       attackCooldown: 0,
       specialCooldown: 0,
       stun: 0,
+      stunLock: 0,
       dead: 0,
       boost: 0,
       flight: 0,
       poison: 0,
       cough: 0,
+      soaked: 0,
+      prevRaw: 0,
       item: null,
       score: 0,
     };
@@ -454,7 +492,8 @@ export class Game {
           (p.stun > 0 ? Anim.Stunned : 0) |
           (p.dead > 0 ? Anim.Dead : 0) |
           (p.boost > 0 ? Anim.Boosted : 0) |
-          (p.poison > 0 ? Anim.Poisoned : 0),
+          (p.poison > 0 ? Anim.Poisoned : 0) |
+          (p.soaked > 0 ? Anim.Soaked : 0),
         score: Math.floor(p.score),
         item: p.item,
       };
@@ -640,7 +679,18 @@ export class Game {
       return;
     }
     const { stats } = p.character;
+    // Giving up when stuck: works even when stunned or busy.
+    const rawPressed = p.input.buttons & ~p.prevRaw;
+    p.prevRaw = p.input.buttons;
+    if (rawPressed & Button.Suicide) {
+      const pos = p.body.getPosition();
+      this.fx.push({ kind: 'splash', id: p.info.id, x: pos.x * PPM, y: pos.y * PPM });
+      this.kill(p, 'custard');
+      return;
+    }
     p.stun = Math.max(0, p.stun - TICK_DT);
+    p.stunLock = Math.max(0, p.stunLock - TICK_DT);
+    p.soaked = Math.max(0, p.soaked - TICK_DT);
     p.boost = Math.max(0, p.boost - TICK_DT);
     p.flight = Math.max(0, p.flight - TICK_DT);
     p.poison = Math.max(0, p.poison - TICK_DT);
@@ -655,7 +705,7 @@ 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) * (p.poison > 0 ? POISON_SPEED_MULT : 1);
+    const speedMult = (p.boost > 0 ? SMARTIES_SPEED_MULT : 1) * (p.poison > 0 ? POISON_SPEED_MULT : 1) * (p.soaked > 0 ? SOAKED_SPEED_MULT : 1);
 
     if (!locked) {
       const dir = (isDown(input, Button.Right) ? 1 : 0) - (isDown(input, Button.Left) ? 1 : 0);
@@ -667,7 +717,7 @@ export class Game {
 
     p.jumpBuffer = pressed & Button.Jump ? JUMP_BUFFER_TIME : Math.max(0, p.jumpBuffer - TICK_DT);
     if (p.jumpBuffer > 0 && p.sinceGrounded <= COYOTE_TIME) {
-      vy = -stats.jump * Math.sqrt(mobility);
+      vy = -stats.jump * Math.sqrt(mobility) * (p.soaked > 0 ? SOAKED_JUMP_MULT : 1);
       p.jumpBuffer = 0;
       p.sinceGrounded = Infinity;
     } else if (released & Button.Jump && vy < 0) {
@@ -732,6 +782,9 @@ export class Game {
     this.stats.add(p.info.id, cause);
     p.dead = RESPAWN_DELAY_S;
     p.stun = 0;
+    p.stunLock = 0;
+    p.soaked = 0;
+    p.poison = 0;
     p.item = null;
     p.body.setLinearVelocity({ x: 0, y: 0 });
     p.body.setActive(false);
@@ -745,14 +798,103 @@ export class Game {
   }
 
   /**
-   * 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.
+   * The spoon catapult: from the custard surface, it throws the player on an arc that lands on
+   * a ledge of the cake (so nobody spends the round falling back in). Without any reachable
+   * ledge, it throws blind from a column of open air, toward the closest spawn point.
    */
   private spoonLaunch(p: ServerPlayer): void {
     p.dead = 0;
     p.body.setActive(true);
+    const r = p.bodyRadius;
+    const surface = Math.min(this.custardY, this.terrain.height) - r - 4;
+    const spots = this.landingSpots(r, surface);
+    // Shuffle (seeded): players come back in different places.
+    for (let i = spots.length - 1; i > 0; i--) {
+      const j = this.rng.int(0, i);
+      [spots[i], spots[j]] = [spots[j], spots[i]];
+    }
+    for (const [tx, ty] of spots.slice(0, SPOON_TARGET_TRIES)) {
+      const columns: number[] = [];
+      for (let x = SPOON_COLUMN_STEP; x < this.terrain.width - SPOON_COLUMN_STEP; x += SPOON_COLUMN_STEP) columns.push(x);
+      columns.sort((a, b) => Math.abs(a - tx) - Math.abs(b - tx));
+      let tried = 0;
+      for (const bx of columns) {
+        if (tried >= SPOON_LAUNCH_TRIES) break;
+        if (!this.discIsFree(bx, surface, r)) continue;
+        tried++;
+        const arc = this.spoonArc(bx, surface, tx, ty, r);
+        if (!arc) continue;
+        this.throwFromSpoon(p, bx, surface, arc.vx, arc.vy, arc.time);
+        return;
+      }
+    }
+    this.blindSpoonLaunch(p, surface);
+  }
+
+  /**
+   * Where a body of radius `r` can land: the top of the cake seen from the sky, where the ground
+   * is flat enough under the whole body and above the custard. Returns body centers.
+   */
+  private landingSpots(r: number, surface: number): [number, number][] {
+    const spots: [number, number][] = [];
+    const top = (x: number): number => {
+      for (let y = 0; y < surface; y++) if (this.terrain.isSolid(x, y)) return y;
+      return Infinity;
+    };
+    for (let x = Math.ceil(r) + 2; x < this.terrain.width - r - 2; x += SPOON_SPOT_STEP) {
+      const y = top(x);
+      if (y === Infinity || y > surface - r) continue;
+      const left = top(Math.round(x - r * 0.7));
+      const right = top(Math.round(x + r * 0.7));
+      if (Math.abs(left - y) > SPOON_SPOT_FLATNESS || Math.abs(right - y) > SPOON_SPOT_FLATNESS) continue;
+      spots.push([x, Math.min(y, left, right) - r - 3]);
+    }
+    return spots;
+  }
+
+  /**
+   * A ballistic arc from (bx, by) peaking SPOON_APEX_PX above the higher end and coming down on
+   * (tx, ty), checked against the terrain. Null if it is too far or hits the cake on the way.
+   */
+  private spoonArc(bx: number, by: number, tx: number, ty: number, r: number): { vx: number; vy: number; time: number } | null {
+    const g = GRAVITY_PX;
+    const apex = Math.min(by, ty) - SPOON_APEX_PX;
+    const vy0 = Math.sqrt(2 * g * (by - apex));
+    const disc = vy0 * vy0 - 2 * g * (by - ty);
+    if (disc < 0) return null;
+    const time = (vy0 + Math.sqrt(disc)) / g;
+    const vx = (tx - bx) / time;
+    if (Math.abs(vx) > SPOON_MAX_VX) return null;
+    // The last few px of the descent end on the ledge: check with a slightly smaller body.
+    for (let t = 0; t < time; t += TICK_DT) {
+      const x = bx + vx * t;
+      const y = by - vy0 * t + (g * t * t) / 2;
+      if (!this.discIsFree(x, y, r - 1)) return null;
+    }
+    return { vx, vy: -vy0, time };
+  }
+
+  /** True when no terrain pixel lies on the disc's outline or center (sampled). */
+  private discIsFree(x: number, y: number, r: number): boolean {
+    if (this.terrain.isSolid(Math.round(x), Math.round(y))) return false;
+    for (let k = 0; k < 12; k++) {
+      const a = (k / 12) * Math.PI * 2;
+      if (this.terrain.isSolid(Math.round(x + Math.cos(a) * r), Math.round(y + Math.sin(a) * r))) return false;
+    }
+    return true;
+  }
+
+  private throwFromSpoon(p: ServerPlayer, x: number, y: number, vx: number, vy: number, flight: number): void {
+    p.body.setPosition({ x: x / PPM, y: y / PPM });
+    p.body.setLinearVelocity({ x: vx / PPM, y: vy / PPM });
+    p.body.setAwake(true);
+    p.flight = flight;
+    this.fx.push({ kind: 'spoon', id: p.info.id, x, y: this.custardY });
+  }
+
+  /** No reachable ledge: a column of open air as close as possible to a random spawn point. */
+  private blindSpoonLaunch(p: ServerPlayer, surface: number): void {
     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) {
@@ -763,17 +905,12 @@ export class Game {
       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.
+    // Aim the arc at the spawn closest to the launch column: 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 });
+    const vx = Math.max(-SPOON_BLIND_MAX_VX, Math.min(SPOON_BLIND_MAX_VX, (tx - bestX) / flight));
+    this.throwFromSpoon(p, bestX, surface, vx, -SPOON_LAUNCH_SPEED, flight);
   }
 
   private columnIsFree(x: number, radius: number, top: number, bottom: number): boolean {
@@ -800,9 +937,10 @@ export class Game {
       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);
-      const kb = knockback * target.character.knockbackTaken();
+      const guarded = target.stunLock > 0 && target.stun === 0;
+      const kb = knockback * target.character.knockbackTaken() * (guarded ? STUN_GUARD_KNOCKBACK : 1);
       target.body.setLinearVelocity({ x: ((nx / len) * kb) / PPM, y: ((ny / len) * kb) / PPM });
-      target.stun = Math.max(target.stun, stun);
+      this.stunPlayer(target, 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);
@@ -813,6 +951,29 @@ export class Game {
     return count;
   }
 
+  /**
+   * Stuns a player, unless they were stunned a moment ago: a stun is followed by STUN_GUARD_S
+   * of immunity, and hits during a stun do not extend it, so nobody can be stun-locked.
+   */
+  private stunPlayer(p: ServerPlayer, seconds: number): void {
+    if (seconds <= 0 || p.stunLock > 0) return;
+    p.stun = seconds;
+    p.stunLock = seconds + STUN_GUARD_S;
+  }
+
+  /** Bébé Pompier's spray: drenched players run and jump less for a while. */
+  private drench(by: ServerPlayer, x: number, y: number, r: number): number {
+    let count = 0;
+    for (const t of this.alivePlayers()) {
+      if (t === by) continue;
+      const pos = t.body.getPosition();
+      if (Math.hypot(pos.x * PPM - x, pos.y * PPM - y) > r + t.bodyRadius) continue;
+      t.soaked = SOAKED_S;
+      count++;
+    }
+    return count;
+  }
+
   private contextFor(p: ServerPlayer, input: InputState): CharacterContext {
     const pos = p.body.getPosition();
     const vel = p.body.getLinearVelocity();
@@ -834,6 +995,7 @@ export class Game {
       isSolid: (x, y) => this.terrain.isSolid(Math.round(x), Math.round(y)),
       standing: this.standing(p),
       shove: (x, y, r, vx, vy, fraction) => this.shove(p, x, y, r, vx, vy, fraction),
+      drench: (x, y, r) => this.drench(p, x, y, r),
       cloud: (x, y, r, life) => this.clouds.push({ x, y, r, life, maxLife: life, owner: p.info.id }),
       swapWithRandomPlayer: () => this.swapWithRandom(p),
       cue: (cue) => {
@@ -913,7 +1075,10 @@ export class Game {
     this.clouds = this.clouds.filter((c) => c.life > 0);
   }
 
-  /** Soaked sponge slowly sags: a few soaked pixels in recently soaked areas crumble (readme §4). */
+  /**
+   * Soaked sponge sags: a few soaked pixels in recently soaked areas crumble (readme §4), and
+   * it gives way under whoever stands on it, a trap Bébé Pompier sets for the others.
+   */
   private updateSoakZones(): void {
     if (this.soakZones.length === 0) return;
     const { width, height, data } = this.terrain;
@@ -922,6 +1087,15 @@ export class Game {
       z.next -= TICK_DT;
       if (z.next > 0) continue;
       z.next = SOAK_SAG_INTERVAL_S;
+      for (const p of this.alivePlayers()) {
+        const pos = p.body.getPosition();
+        const fx = Math.round(pos.x * PPM);
+        const fy = Math.round(pos.y * PPM + p.bodyRadius + 2);
+        if (Math.hypot(fx - z.x, fy - z.y) > z.r || !p.grounded) continue;
+        if (fx >= 0 && fy >= 0 && fx < width && fy < height && data[fy * width + fx] === MATERIAL_SOAKED) {
+          this.queueOp({ kind: 'carve', x: fx, y: fy, r: SOAK_SAG_RADIUS + 2, power: SOAK_SAG_POWER });
+        }
+      }
       // Pick a soaked pixel near the bottom of the zone, where the weight rests.
       for (let tries = 0; tries < 12; tries++) {
         const x = Math.round(z.x + this.rng.range(-z.r, z.r));
@@ -953,11 +1127,29 @@ export class Game {
       if (this.cherry && this.cherry.holder === 0) push(this.cherry.body);
     }
     this.timeLeft -= TICK_DT;
+    this.cakeCheck -= TICK_DT;
+    if (this.cakeCheck <= 0) {
+      this.cakeCheck = CAKE_CHECK_INTERVAL_S;
+      if (this.cakeLeft() < MIN_CAKE_PIXELS) this.cakeGone = true;
+    }
     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);
   }
 
+  /** Solid pixels above the custard, plate excluded: what is left to stand on and fight over. */
+  private cakeLeft(): number {
+    const { width, data } = this.terrain;
+    const PLATE = MATERIAL_PLATE;
+    const end = Math.max(0, Math.min(this.terrain.height, Math.floor(this.custardY))) * width;
+    let n = 0;
+    for (let i = 0; i < end; i++) {
+      const m = data[i];
+      if (m !== 0 && m !== PLATE) n++;
+    }
+    return n;
+  }
+
   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 });
@@ -1189,7 +1381,7 @@ export class Game {
       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);
+      this.stunPlayer(other, WHISK_STUN_S);
       if (this.cherry?.holder === other.info.id) this.dropCherry(other);
     }
     const { width, height, data } = this.terrain;

+ 56 - 7
src/server/net.ts

@@ -1,6 +1,6 @@
 import { Packr } from 'msgpackr';
 import { WebSocketServer, type WebSocket } from 'ws';
-import { CHARACTER_IDS, isCharacterId } from '../shared/characters';
+import { isCharacterId, PLAYABLE_CHARACTER_IDS } from '../shared/characters';
 import {
   HASH_CHECK_INTERVAL_S,
   MATCH_END_PAUSE_S,
@@ -8,6 +8,7 @@ import {
   MAX_PLAYERS,
   PROTOCOL_VERSION,
   ROUND_DURATION_S,
+  ROUND_END_AFTER_PICK_S,
   ROUND_END_PAUSE_S,
   ROUNDS_PER_MATCH,
   START_COUNTDOWN_S,
@@ -17,6 +18,7 @@ import {
 } from '../shared/constants';
 import {
   isClientMessage,
+  RANDOM_CHARACTER,
   type LobbyPlayer,
   type MapSummary,
   type Phase,
@@ -216,6 +218,8 @@ export class GameServer {
       else if (msg.t === 'rotation') this.onRotation(client.player, msg.rotation);
       else if (msg.t === 'pickMap') this.onPickMap(client.player, msg.mapId);
       else if (msg.t === 'imposeCharacter') this.onImposeCharacter(client.player, msg.characterId);
+      else if (msg.t === 'character') this.onCharacter(client.player, msg.characterId);
+      else if (msg.t === 'stopMatch') this.onStopMatch(client.player);
       else if (msg.t === 'desync') {
         console.error(
           `[net] DESYNC: player ${client.player.id} (${client.player.name}) has hash ${msg.hash} at seq ${msg.seq}; ` +
@@ -248,11 +252,11 @@ export class GameServer {
     if (version !== PROTOCOL_VERSION) return reject('version');
     if (client.player) return;
     if (this.playerCount >= MAX_PLAYERS) return reject('full');
-    if (!isCharacterId(characterId)) return reject('character');
+    if (!this.validCharacter(characterId)) return reject('character');
 
     const player: LobbyPlayer = { id: this.nextPlayerId++, name: rawName.slice(0, 24) || 'Joueur', characterId, ready: false };
     client.player = player;
-    const info = this.game.addPlayer(player.name, characterId, player.id);
+    const info = this.game.addPlayer(player.name, this.resolveCharacter(characterId), player.id);
     this.send(client, {
       t: 'welcome',
       version: PROTOCOL_VERSION,
@@ -295,13 +299,54 @@ export class GameServer {
     if (!rotationPool(this.rotation, this.opts.maps).some((m) => m.id === mapId)) return;
     this.pickedMap = mapId;
     this.broadcast({ t: 'mapPicked', mapId, by: player.id });
+    this.shortenPauseIfPicked();
   }
 
   private onImposeCharacter(player: LobbyPlayer, characterId: string): void {
     if (this.phase !== 'roundEnd' || player.id !== this.picker || this.leader === null || this.imposed !== null) return;
-    if (!isCharacterId(characterId)) return;
+    if (!isCharacterId(characterId) || !PLAYABLE_CHARACTER_IDS.includes(characterId)) return;
     this.imposed = characterId;
     this.broadcast({ t: 'characterImposed', playerId: this.leader, characterId, by: player.id });
+    this.shortenPauseIfPicked();
+  }
+
+  /** The last-placed player made every pick: no need to keep everyone waiting. */
+  private shortenPauseIfPicked(): void {
+    if (this.pickedMap === null || (this.leader !== null && this.imposed === null)) return;
+    this.pause = Math.min(this.pause, Math.round(ROUND_END_AFTER_PICK_S * TICK_RATE));
+    this.broadcast({ t: 'nextIn', seconds: this.pause / TICK_RATE });
+  }
+
+  /** Changing character during the warm-up: the player is put back in the game with the new one. */
+  private onCharacter(player: LobbyPlayer, characterId: string): void {
+    if (this.phase !== 'lobby' || !this.validCharacter(characterId) || player.characterId === characterId) return;
+    player.characterId = characterId;
+    this.game.removePlayer(player.id);
+    this.broadcast({ t: 'playerLeft', id: player.id });
+    const info = this.game.addPlayer(player.name, this.resolveCharacter(characterId), player.id);
+    this.broadcast({ t: 'playerJoined', player: info });
+    this.broadcastLobby();
+  }
+
+  /** The host stops the match: everyone goes back to the warm-up, to change characters or maps. */
+  private onStopMatch(player: LobbyPlayer): void {
+    if (this.phase === 'lobby' || player.id !== this.hostId()) return;
+    this.picker = null;
+    this.pickedMap = null;
+    this.leader = null;
+    this.imposed = null;
+    this.broadcast({ t: 'matchStopped' });
+    this.backToLobby();
+  }
+
+  private validCharacter(id: string): boolean {
+    // The test character stays allowed for tests and development.
+    return id === RANDOM_CHARACTER || isCharacterId(id);
+  }
+
+  /** A random pick is drawn again for every round (and every warm-up). */
+  private resolveCharacter(id: string): string {
+    return id === RANDOM_CHARACTER ? PLAYABLE_CHARACTER_IDS[this.rng.int(0, PLAYABLE_CHARACTER_IDS.length - 1)] : id;
   }
 
   private updateCountdown(): void {
@@ -342,7 +387,7 @@ export class GameServer {
       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 });
+      this.broadcast({ t: 'matchEnd', reason: this.game.endReason, 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';
@@ -356,6 +401,7 @@ export class GameServer {
       const choices = rotationPool(this.rotation, this.opts.maps);
       this.broadcast({
         t: 'roundEnd',
+        reason: this.game.endReason,
         round: this.round,
         rounds: this.rounds,
         results,
@@ -363,7 +409,7 @@ export class GameServer {
         picker: this.picker,
         choices,
         leader: this.leader,
-        characters: CHARACTER_IDS.filter((id) => id !== 'generic'),
+        characters: PLAYABLE_CHARACTER_IDS,
       });
     }
     this.broadcastLobby();
@@ -379,13 +425,16 @@ export class GameServer {
   private backToLobby(): void {
     this.phase = 'lobby';
     this.round = 0;
+    this.pause = 0;
+    this.countdown = null;
+    for (const p of this.players()) p.ready = false;
     this.game = this.newGame(null, this.warmupMap());
     this.populateAndAnnounce();
   }
 
   /** Adds every player to the fresh game (with a one-round imposed character, if any) and announces it. */
   private populateAndAnnounce(imposed: { id: number; characterId: string } | null = null): void {
-    for (const p of this.players()) this.game.addPlayer(p.name, imposed?.id === p.id ? imposed.characterId : p.characterId, p.id);
+    for (const p of this.players()) this.game.addPlayer(p.name, imposed?.id === p.id ? imposed.characterId : this.resolveCharacter(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();
   }

+ 5 - 1
src/shared/characters/Character.ts

@@ -35,6 +35,8 @@ export const Anim = {
   Busy: 1 << 6,
   /** Underground (Gégé swimming in the cake): drawn as a bump. */
   Underground: 1 << 7,
+  /** Drenched by Bébé Pompier's spray: slower run and jump. */
+  Soaked: 1 << 8,
 } as const;
 
 /** A random spell of the Magicien Raté. */
@@ -87,6 +89,8 @@ export interface CharacterContext {
    * (vx, vy) px/s; players who are not stunned get `fraction` of it. Returns how many things moved.
    */
   shove(x: number, y: number, r: number, vx: number, vy: number, fraction: number): number;
+  /** Drenches the other players touching the disc: they run and jump less for a while. Returns how many. */
+  drench(x: number, y: number, r: number): number;
   /** Leaves a toxic cloud: other players inside are poisoned (blurred vision, coughing). */
   cloud(x: number, y: number, r: number, life: number): void;
   /** Swaps places with another player at random. Returns false if alone. */
@@ -95,7 +99,7 @@ export interface CharacterContext {
   cue(kind: CharacterCue): void;
 }
 
-export type CharacterCue = 'prout' | 'water' | `spell-${Spell}` | 'emerge' | 'lick';
+export type CharacterCue = 'prout' | 'water' | `spell-${Spell}` | 'emerge' | 'lick' | 'pounce' | 'backfire';
 
 /** Base class for all characters. Pure logic: no physics engine, no rendering. */
 export abstract class Character {

+ 48 - 11
src/shared/characters/Chat.ts

@@ -3,14 +3,18 @@ import { aimDir } from './Mamie';
 
 /**
  * Le Chat.
- * - Attack: paw swipe.
- * - Special: shoves what is in front of him: falling chunks, loose items, and stunned players
- *   (others only budge a little). Perfect for pushing someone off the edge.
- * - Flaw: now and then he stops to lick his paw for a second, whatever is going on.
+ * - Attack: paw swipe, claws out: it scratches the cake too.
+ * - Special: pounce toward the aim, claws first: three scratches dug through the cake along the
+ *   way, and whoever he lands on is sent flying. At the end of the leap he shoves what is in
+ *   front of him: falling chunks, loose items, and players (stunned ones at full force).
+ * - Flaw: now and then he stops to lick his paw, whatever is going on.
  */
 export class Chat extends Character {
   private pawTimer = 0;
-  private shoveTimer = 0;
+  private pounceTime = 0;
+  private pounceX = 1;
+  private pounceY = 0;
+  private scratchTimer = 0;
   private licking = 0;
   /** Seconds before the next lick, drawn on the first tick. */
   private nextLick = -1;
@@ -27,21 +31,26 @@ export class Chat extends Character {
   }
 
   onSpecial(ctx: CharacterContext): void {
-    const p = this.stats.params;
-    const reach = this.size + p.shoveReach / 2;
-    ctx.shove(ctx.x + ctx.facing * reach, ctx.y, p.shoveRadius, ctx.facing * p.shoveSpeed, -p.shoveLift, p.shoveFraction);
-    this.shoveTimer = p.shoveAnim;
+    if (this.pounceTime > 0) return;
+    const [dx, dy] = aimDir(ctx);
+    this.pounceX = dx;
+    this.pounceY = dy;
+    this.pounceTime = this.stats.params.pounceTime;
+    this.scratchTimer = 0;
+    ctx.cue('pounce');
   }
 
   override onTick(ctx: CharacterContext, dt: number): void {
     const p = this.stats.params;
     this.pawTimer = Math.max(0, this.pawTimer - dt);
-    this.shoveTimer = Math.max(0, this.shoveTimer - dt);
+    if (this.pounceTime > 0) this.pounce(ctx, dt);
     if (this.nextLick < 0) this.nextLick = p.lickMin + ctx.random() * (p.lickMax - p.lickMin);
     if (this.licking > 0) {
       this.licking = Math.max(0, this.licking - dt);
       return;
     }
+    // He never stops mid-leap to lick his paw.
+    if (this.pounceTime > 0) return;
     this.nextLick -= dt;
     if (this.nextLick <= 0) {
       this.licking = p.lickTime;
@@ -50,11 +59,39 @@ export class Chat extends Character {
     }
   }
 
+  private pounce(ctx: CharacterContext, dt: number): void {
+    const p = this.stats.params;
+    const dx = this.pounceX;
+    const dy = this.pounceY;
+    this.pounceTime -= dt;
+    ctx.setVelocity(dx * p.pounceSpeed, dy * p.pounceSpeed - p.pounceLift);
+    // Claws first: three parallel scratches across the way.
+    this.scratchTimer -= dt;
+    if (this.scratchTimer <= 0) {
+      this.scratchTimer = p.scratchInterval;
+      const ahead = this.size + p.scratchAhead;
+      for (const side of [-1, 0, 1]) {
+        const x = ctx.x + dx * ahead - dy * side * p.scratchSpacing;
+        const y = ctx.y + dy * ahead + dx * side * p.scratchSpacing;
+        ctx.emitOp({ kind: 'carve', x: Math.round(x), y: Math.round(y), r: Math.round(p.scratchRadius), power: Math.round(p.scratchPower) });
+      }
+    }
+    const fx = ctx.x + dx * (this.size + p.pounceHitReach);
+    const fy = ctx.y + dy * (this.size + p.pounceHitReach);
+    // Landing on someone ends the leap with a big swipe.
+    const landed = ctx.hit(fx, fy, p.pounceHitRadius, p.pounceKnockback, p.pounceStun) > 0;
+    if (landed || this.pounceTime <= 0) {
+      this.pounceTime = 0;
+      ctx.shove(fx, fy, p.shoveRadius, dx * p.shoveSpeed, dy * p.shoveSpeed - p.shoveLift, p.shoveFraction);
+      ctx.setVelocity(ctx.vx * 0.3, ctx.vy * 0.3);
+    }
+  }
+
   override busy(): boolean {
     return this.licking > 0;
   }
 
   override anim(): number {
-    return (this.pawTimer > 0 ? Anim.Attack : 0) | (this.shoveTimer > 0 ? Anim.Special : 0) | (this.licking > 0 ? Anim.Busy : 0);
+    return (this.pawTimer > 0 ? Anim.Attack : 0) | (this.pounceTime > 0 ? Anim.Special : 0) | (this.licking > 0 ? Anim.Busy : 0);
   }
 }

+ 6 - 2
src/shared/characters/Pompier.ts

@@ -6,7 +6,8 @@ import { aimDir } from './Mamie';
  * Bébé Pompier.
  * - Attack: water jet, pushes players (and loose things) away along the aim.
  * - Special: soaks the cake around the aim point: sponge turns into soaked sponge, which is
- *   very soft and slowly sags (see the server's soak zones).
+ *   very soft, sags, and gives way under whoever stands on it (see the server's soak zones).
+ *   Players caught in the spray are drenched: they run and jump less for a while.
  * - Flaw: the jet's recoil sends him flying backwards.
  */
 export class Pompier extends Character {
@@ -35,7 +36,10 @@ export class Pompier extends Character {
       dx = (dx / dist) * p.soakReach;
       dy = (dy / dist) * p.soakReach;
     }
-    ctx.emitOp({ kind: 'convert', x: Math.round(ctx.x + dx), y: Math.round(ctx.y + dy), r: Math.round(p.soakRadius), from: MATERIAL_SPONGE, to: MATERIAL_SOAKED });
+    const x = Math.round(ctx.x + dx);
+    const y = Math.round(ctx.y + dy);
+    ctx.emitOp({ kind: 'convert', x, y, r: Math.round(p.soakRadius), from: MATERIAL_SPONGE, to: MATERIAL_SOAKED });
+    ctx.drench(x, y, p.soakRadius);
     ctx.cue('water');
     this.sprayTimer = p.sprayAnim;
   }

+ 9 - 2
src/shared/characters/Prout.ts

@@ -5,12 +5,15 @@ import { aimDir } from './Mamie';
  * Captain Prout.
  * - Attack: cape swipe.
  * - Special: a small jetpack thrust (toward the aim, mostly up) that leaves a toxic cloud
- *   behind: players inside get blurred vision and cough.
+ *   behind: players inside get blurred vision and cough. Only `airPuffs` thrusts before he
+ *   touches the ground again: he hops, he does not fly.
  * - Flaw: every thrust is heard ("PROUT !" for everyone, giving away where he is).
  */
 export class Prout extends Character {
   private capeTimer = 0;
   private puffTimer = 0;
+  /** Thrusts used since he last stood on the ground. */
+  private puffsUsed = 0;
 
   onAttack(ctx: CharacterContext): void {
     const p = this.stats.params;
@@ -25,6 +28,8 @@ export class Prout extends Character {
 
   onSpecial(ctx: CharacterContext): void {
     const p = this.stats.params;
+    if (this.puffsUsed >= p.airPuffs) return;
+    this.puffsUsed++;
     const [dx] = aimDir(ctx);
     ctx.setVelocity(ctx.vx + dx * p.puffSide, Math.min(ctx.vy, 0) - p.puffLift);
     ctx.cloud(ctx.x, ctx.y + this.size * 0.8, p.cloudRadius, p.cloudLife);
@@ -32,7 +37,9 @@ export class Prout extends Character {
     this.puffTimer = p.puffAnim;
   }
 
-  override onTick(_ctx: CharacterContext, dt: number): void {
+  override onTick(ctx: CharacterContext, dt: number): void {
+    // Back on the ground (not just taking off: the thrust animation is over).
+    if (ctx.grounded && this.puffTimer === 0) this.puffsUsed = 0;
     this.capeTimer = Math.max(0, this.capeTimer - dt);
     this.puffTimer = Math.max(0, this.puffTimer - dt);
   }

+ 5 - 6
src/shared/characters/Pyromanchot.ts

@@ -5,22 +5,21 @@ import { aimDir } from './Mamie';
  * Pyromanchot, the penguin who loves fireworks.
  * - Attack: throws a firecracker that explodes on contact.
  * - Special: belly slide, ending in an explosion where he stops (he is spared).
- * - Flaw: one firecracker in three goes off in his flippers.
+ * - Flaw: now and then (backfireChance) a firecracker goes off in his flippers.
  */
 export class Pyromanchot extends Character {
-  private thrown = 0;
   private slideTime = 0;
   private slideDir = 1;
   private throwTimer = 0;
 
   onAttack(ctx: CharacterContext): void {
     const p = this.stats.params;
-    this.thrown++;
     this.throwTimer = p.throwAnim;
     const blast = this.crackerBlast();
-    if (this.thrown % Math.max(1, Math.round(p.backfireEvery)) === 0) {
-      // Backfire: it blows up right here, and he is not spared.
-      ctx.explode(ctx.x + ctx.facing * this.size * 0.6, ctx.y, blast, false);
+    if (ctx.random() < p.backfireChance) {
+      // Backfire: it blows up right here, and he is not spared (a smaller bang, though).
+      ctx.cue('backfire');
+      ctx.explode(ctx.x + ctx.facing * this.size * 0.6, ctx.y, { ...blast, knockback: blast.knockback * p.backfireKnockback }, false);
       return;
     }
     const [dx, dy] = aimDir(ctx);

+ 29 - 5
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 = 9;
+export const PROTOCOL_VERSION = 10;
 
 export const NET_PORT = 47100;
 export const DISCOVERY_PORT = 47101;
@@ -20,14 +20,38 @@ 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 round results before the next round (s): time for the last-placed player to pick. */
+export const ROUND_END_PAUSE_S = 20;
+/** Once the last-placed player has made every pick, the results stay this long (s). */
+export const ROUND_END_AFTER_PICK_S = 3;
+/**
+ * The round ends early when the cake left above the custard (plate excluded) falls below this
+ * many pixels: about a 150 × 100 px block, too little to fight over.
+ */
+export const MIN_CAKE_PIXELS = 15000;
+/** How often the cake left is measured (s). */
+export const CAKE_CHECK_INTERVAL_S = 1;
 /** 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). */
+/** Launch speed of the spoon catapult when no landing spot is found (px/s). */
 export const SPOON_LAUNCH_SPEED = 950;
+/** The spoon aims at a ledge: its arc peaks this far above the highest of the launch and the landing (px). */
+export const SPOON_APEX_PX = 140;
+/** Fastest sideways throw of the spoon (px/s). */
+export const SPOON_MAX_VX = 900;
+/** Landing spots tried, and launch columns tried for each, before falling back to a blind throw. */
+export const SPOON_TARGET_TRIES = 24;
+export const SPOON_LAUNCH_TRIES = 10;
+/** After a stun, a player cannot be stunned again for this long (s), so nobody gets stun-locked. */
+export const STUN_GUARD_S = 1;
+/** Knockback taken while protected by the stun guard, relative to normal. */
+export const STUN_GUARD_KNOCKBACK = 0.6;
+/** Soaked by Bébé Pompier: how long, and how it slows the run and the jump. */
+export const SOAKED_S = 2.5;
+export const SOAKED_SPEED_MULT = 0.6;
+export const SOAKED_JUMP_MULT = 0.75;
 /** 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;
@@ -66,7 +90,7 @@ export const MATERIAL_BISCUIT = 5;
 export const MATERIAL_SOAKED = 8;
 /** Soaked sponge sags: for SOAK_SAG_TIME_S after a soak, a few soaked pixels crumble every interval. */
 export const SOAK_SAG_TIME_S = 8;
-export const SOAK_SAG_INTERVAL_S = 0.2;
+export const SOAK_SAG_INTERVAL_S = 0.08;
 export const SOAK_SAG_RADIUS = 6;
 /** Only soaked sponge (and whipped cream) is that soft. */
 export const SOAK_SAG_POWER = 3;

+ 2 - 0
src/shared/input.ts

@@ -8,6 +8,8 @@ export const Button = {
   Special: 1 << 5,
   /** Uses the carried item (throws the candle). */
   Use: 1 << 6,
+  /** Gives up when stuck: straight into the custard, back with the spoon. */
+  Suicide: 1 << 7,
 } as const;
 
 export interface InputState {

+ 19 - 2
src/shared/protocol.ts

@@ -11,8 +11,13 @@ export interface PlayerInfo {
   characterId: string;
   /** Body radius (px), so clients can draw without loading every character file. */
   radius: number;
+  /** 0 for the first player on this character, 1 for the second...: drawn in other colors. */
+  variant: number;
 }
 
+/** Character id meaning "draw one at random for every round". */
+export const RANDOM_CHARACTER = 'random';
+
 export interface PlayerState {
   id: number;
   x: number;
@@ -134,6 +139,7 @@ export type Phase = (typeof PHASES)[number];
 export interface LobbyPlayer {
   id: number;
   name: string;
+  /** Chosen character, or RANDOM_CHARACTER. */
   characterId: string;
   ready: boolean;
 }
@@ -181,6 +187,10 @@ export type ClientMessage =
   | { t: 'pickMap'; mapId: string }
   /** ...and imposes a character on the leader for the next round. */
   | { t: 'imposeCharacter'; characterId: string }
+  /** Changes character during the warm-up (a character id or RANDOM_CHARACTER). */
+  | { t: 'character'; characterId: string }
+  /** Host only: stops the match and brings everyone back to the warm-up. */
+  | { t: 'stopMatch' }
   /** The client's bitmap hash differs from the server's at `seq`. */
   | { t: 'desync'; seq: number; hash: number };
 
@@ -225,6 +235,8 @@ export type ServerMessage =
   | { t: 'start'; mapId: string; seed: number; players: PlayerInfo[]; round: number; rounds: number }
   | {
       t: 'roundEnd';
+      /** The timer ran out, or there was (almost) no cake left. */
+      reason: 'time' | 'noCake';
       round: number;
       rounds: number;
       results: RoundResult[];
@@ -236,7 +248,11 @@ export type ServerMessage =
       leader: number | null;
       characters: string[];
     }
-  | { t: 'matchEnd'; results: RoundResult[]; trophies: Trophy[]; lobbyIn: number }
+  | { t: 'matchEnd'; reason: 'time' | 'noCake'; results: RoundResult[]; trophies: Trophy[]; lobbyIn: number }
+  /** The host stopped the match: everyone is back to the warm-up. */
+  | { t: 'matchStopped' }
+  /** The results screen now ends in `seconds` (the picks are done). */
+  | { t: 'nextIn'; seconds: number }
   /** Consistency check: the server's bitmap hash once `seq` terrain events have been applied. */
   | { t: 'hash'; seq: number; hash: number };
 
@@ -251,7 +267,8 @@ export function isClientMessage(msg: unknown): msg is ClientMessage {
   if (m.t === 'input') return typeof m.buttons === 'number' && typeof m.aimX === 'number' && typeof m.aimY === 'number';
   if (m.t === 'ready') return typeof m.ready === 'boolean';
   if (m.t === 'pickMap') return typeof m.mapId === 'string';
-  if (m.t === 'imposeCharacter') return typeof m.characterId === 'string';
+  if (m.t === 'imposeCharacter' || m.t === 'character') return typeof m.characterId === 'string';
+  if (m.t === 'stopMatch') return true;
   if (m.t === 'rotation') {
     const r = m.rotation as Partial<Rotation> | undefined;
     return (

+ 86 - 16
tests/characters.test.ts

@@ -9,9 +9,14 @@ import { ASSETS, realMaterials } from './helpers';
 
 const materials = realMaterials();
 
-function newGame(): Game {
+/** `params` overrides character tuning values (by character id), e.g. to force a random outcome. */
+function newGame(params: Record<string, Record<string, number>> = {}): Game {
   const { definition, terrain } = loadMap(ASSETS, 'gateau-etages', materials);
-  return new Game({ map: definition, terrain, materials, seed: 5, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
+  const loadCharacter = (id: string) => {
+    const stats = loadCharacterStats(ASSETS, id);
+    return { ...stats, params: { ...stats.params, ...params[id] } };
+  };
+  return new Game({ map: definition, terrain, materials, seed: 5, loadCharacter });
 }
 
 function run(game: Game, ticks: number): FxEvent[] {
@@ -102,7 +107,7 @@ describe('Mamie Tricot', () => {
     const v = me(game, victim.id);
     expect(v.anim & Anim.Stunned).toBeTruthy();
     expect(Math.hypot(v.vx, v.vy)).toBeLessThan(400);
-    run(game, Math.round(0.6 * TICK_RATE));
+    run(game, Math.round(0.4 * TICK_RATE));
     expect(me(game, victim.id).anim & Anim.Stunned).toBeTruthy();
   });
 
@@ -128,33 +133,43 @@ describe('Mamie Tricot', () => {
 });
 
 describe('Pyromanchot', () => {
-  it('throws firecrackers that explode away from him, and every third one backfires', () => {
-    const game = newGame();
+  it('throws firecrackers far away from him', () => {
+    const game = newGame({ pyromanchot: { backfireChance: 0 } });
     const { id } = game.addPlayer('Pyro', 'pyromanchot');
     run(game, 90);
     const explosions: { x: number; y: number }[] = [];
     for (let shot = 0; shot < 3; shot++) {
       const p = me(game, id);
-      const fx = press(game, id, Button.Attack, p.x + 300, p.y - 100).concat(run(game, TICK_RATE));
+      // Aimed up and across: it flies until it lands somewhere on the cake.
+      const fx = press(game, id, Button.Attack, p.x + 300, p.y - 300).concat(run(game, 2 * TICK_RATE));
       for (const e of fx) if (e.kind === 'explosion') explosions.push({ x: e.x - p.x, y: e.y - p.y });
-      run(game, Math.round(0.5 * TICK_RATE));
     }
     expect(explosions).toHaveLength(3);
-    // The first two flew off; the third went off right next to him.
-    expect(Math.hypot(explosions[0].x, explosions[0].y)).toBeGreaterThan(60);
-    expect(Math.hypot(explosions[1].x, explosions[1].y)).toBeGreaterThan(60);
-    expect(Math.hypot(explosions[2].x, explosions[2].y)).toBeLessThan(40);
+    for (const e of explosions) expect(Math.hypot(e.x, e.y)).toBeGreaterThan(200);
   });
 
-  it('a backfire stuns him', () => {
+  it('only some firecrackers backfire', () => {
     const game = newGame();
     const { id } = game.addPlayer('Pyro', 'pyromanchot');
     run(game, 90);
-    for (let shot = 0; shot < 3; shot++) {
+    let backfires = 0;
+    const shots = 40;
+    for (let shot = 0; shot < shots; shot++) {
       const p = me(game, id);
-      press(game, id, Button.Attack, p.x + 300, p.y - 100);
-      if (shot < 2) run(game, TICK_RATE);
+      const fx = press(game, id, Button.Attack, p.x + 300, p.y - 300);
+      if (fx.some((e) => e.kind === 'cue' && e.cue === 'backfire')) backfires++;
+      run(game, Math.round(0.5 * TICK_RATE));
     }
+    expect(backfires).toBeGreaterThan(0);
+    expect(backfires).toBeLessThan(shots / 4);
+  });
+
+  it('a backfire stuns him', () => {
+    const game = newGame({ pyromanchot: { backfireChance: 1 } });
+    const { id } = game.addPlayer('Pyro', 'pyromanchot');
+    run(game, 90);
+    const p = me(game, id);
+    press(game, id, Button.Attack, p.x + 300, p.y - 100);
     expect(me(game, id).anim & Anim.Stunned).toBeTruthy();
   });
 
@@ -238,6 +253,20 @@ describe('Bébé Pompier', () => {
     run(game, 4 * TICK_RATE);
     expect(soaked()).toBeLessThan(after); // crumbling
   });
+
+  it('the spray drenches players caught in it: slower for a while', () => {
+    const game = newGame();
+    const baby = game.addPlayer('Bébé', 'pompier');
+    const victim = game.addPlayer('Victime', 'generic');
+    game.teleport(baby.id, 600, 420);
+    run(game, 60);
+    const b = me(game, baby.id);
+    game.teleport(victim.id, b.x + 100, b.y);
+    run(game, 1);
+    press(game, baby.id, Button.Special, b.x + 100, b.y);
+    expect(me(game, victim.id).anim & Anim.Soaked).toBeTruthy();
+    expect(me(game, baby.id).anim & Anim.Soaked).toBeFalsy();
+  });
 });
 
 describe('Captain Prout', () => {
@@ -261,9 +290,50 @@ describe('Captain Prout', () => {
     run(game, 2);
     expect(me(game, prout.id).anim & Anim.Poisoned).toBeFalsy(); // immune to his own clouds
   });
+
+  it('only a few thrusts before touching the ground again: he hops, he does not fly', () => {
+    const game = newGame();
+    const prout = game.addPlayer('Prout', 'prout');
+    game.teleport(prout.id, 600, 420);
+    run(game, 60);
+    let puffs = 0;
+    // One try per cooldown: he does not land before the fourth one.
+    for (let i = 0; i < 4; i++) {
+      const p = me(game, prout.id);
+      const fx = press(game, prout.id, Button.Special, p.x, p.y - 100);
+      if (fx.some((e) => e.kind === 'cue' && e.cue === 'prout')) puffs++;
+      if (i < 3) {
+        run(game, 24);
+        expect(me(game, prout.id).grounded).toBe(false);
+      }
+    }
+    expect(puffs).toBe(loadCharacterStats(ASSETS, 'prout').params.airPuffs);
+  });
 });
 
 describe('Le Chat', () => {
+  it('pounces claws first: digs scratches and sends the player he lands on flying', () => {
+    const game = newGame();
+    const cat = game.addPlayer('Chat', 'chat');
+    const victim = game.addPlayer('Victime', 'generic');
+    game.teleport(cat.id, 600, 420);
+    run(game, 60);
+    const c = me(game, cat.id);
+    game.teleport(victim.id, c.x + 90, c.y);
+    run(game, 1);
+    const before = game.terrain.data.reduce((n, m) => n + (m !== 0 ? 1 : 0), 0);
+    const fx = press(game, cat.id, Button.Special, c.x + 200, c.y + 30);
+    expect(fx).toContainEqual(expect.objectContaining({ kind: 'cue', cue: 'pounce' }));
+    let fastest = 0;
+    for (let i = 0; i < 20; i++) {
+      run(game, 1);
+      const v = me(game, victim.id);
+      fastest = Math.max(fastest, Math.hypot(v.vx, v.vy));
+    }
+    expect(fastest).toBeGreaterThan(300);
+    expect(game.terrain.data.reduce((n, m) => n + (m !== 0 ? 1 : 0), 0)).toBeLessThan(before);
+  });
+
   it('shoves a stunned player hard', () => {
     const game = newGame();
     const cat = game.addPlayer('Chat', 'chat');
@@ -286,7 +356,7 @@ describe('Le Chat', () => {
     const cat = game.addPlayer('Chat', 'chat');
     run(game, 60);
     let licked = false;
-    for (let i = 0; i < 16 * TICK_RATE && !licked; i++) licked = game.step().fx.some((e) => e.kind === 'cue' && e.cue === 'lick');
+    for (let i = 0; i < 26 * TICK_RATE && !licked; i++) licked = game.step().fx.some((e) => e.kind === 'cue' && e.cue === 'lick');
     expect(licked).toBe(true);
     expect(me(game, cat.id).anim & Anim.Busy).toBeTruthy();
     const x0 = me(game, cat.id).x;

+ 44 - 0
tests/net.test.ts

@@ -303,4 +303,48 @@ describe('LAN server (M3)', () => {
     await until(() => a.last('start')?.round === 0);
     expect(a.last('start')!.players.find((p) => p.id === a.playerId)!.characterId).toBe('generic');
   });
+  it('the host can stop a match: everyone goes back to the warm-up', async () => {
+    const s = await startServer({ rounds: 4, roundDuration: 60 });
+    const host = new TestClient(s.port);
+    const guest = new TestClient(s.port);
+    clients.push(host, guest);
+    await host.hello('Hote');
+    await guest.hello('Invite');
+    await flush();
+    host.send({ t: 'ready', ready: true });
+    guest.send({ t: 'ready', ready: true });
+    await flush();
+    tick(START_COUNTDOWN_S * TICK_RATE);
+    await until(() => guest.last('start')?.round === 1);
+    guest.send({ t: 'stopMatch' }); // not the host: ignored
+    await flush();
+    tick(1);
+    expect(s.currentPhase).toBe('playing');
+    host.send({ t: 'stopMatch' });
+    await until(() => guest.last('matchStopped') !== undefined);
+    await until(() => guest.last('start')?.round === 0);
+    expect(s.currentPhase).toBe('lobby');
+    expect(guest.last('lobby')).toMatchObject({ phase: 'lobby', countdown: null });
+  });
+
+  it('players change character during the warm-up, a random pick is drawn each round', async () => {
+    const s = await startServer();
+    const a = new TestClient(s.port);
+    const b = new TestClient(s.port);
+    clients.push(a, b);
+    await a.hello('Alice', 'random');
+    await b.hello('Bob', 'castor');
+    await until(() => a.last('welcome') !== undefined && b.last('welcome') !== undefined);
+    const drawn = a.last('welcome')!.players.find((p) => p.id === a.playerId)!.characterId;
+    expect(['castor', 'mamie', 'pyromanchot', 'gege', 'pompier', 'prout', 'chat', 'magicien']).toContain(drawn);
+    b.send({ t: 'character', characterId: 'chat' });
+    // (Bob's own arrival was announced as a playerJoined too, with his first character.)
+    await until(() => a.last('playerJoined')?.player.characterId === 'chat');
+    expect(a.last('playerJoined')!.player.id).toBe(b.playerId);
+    await until(() => a.last('lobby')!.players.find((p) => p.id === b.playerId)!.characterId === 'chat');
+    // Two players on the same character get different color variants.
+    a.send({ t: 'character', characterId: 'chat' });
+    await until(() => b.last('playerJoined')?.player.id === a.playerId);
+    expect(b.last('playerJoined')!.player).toMatchObject({ characterId: 'chat', variant: 1 });
+  });
 });

+ 80 - 1
tests/rules.test.ts

@@ -3,7 +3,7 @@ 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 { CHERRY_REGRAB_DELAY, FINAL_RUSH_S, MIN_CAKE_PIXELS, RESPAWN_DELAY_S, SMARTIES_DURATION_S, STUN_GUARD_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';
@@ -75,12 +75,91 @@ describe('custard and the spoon', () => {
     }
   });
 
+  it('the spoon finds the last little island of cake', () => {
+    const game = newRound();
+    // Wipe the cake, plate excluded, except for a 120 px block on the far right.
+    const { width, height, data } = game.terrain;
+    for (let y = 0; y < height; y++) {
+      for (let x = 0; x < width; x++) {
+        const i = y * width + x;
+        if (data[i] !== 255 && !(x >= 1600 && x < 1720 && y >= 820)) data[i] = 0;
+      }
+    }
+    game.terrain.markDirty({ x0: 0, y0: 0, x1: width, y1: height });
+    const { id } = game.addPlayer('Plouf', 'generic');
+    game.teleport(id, 60, 900); // left of the plate: nothing below but custard
+    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)).toEqual([]);
+    const p = me(game, id);
+    expect(p.grounded).toBe(true);
+    expect(p.x).toBeGreaterThan(1590);
+    expect(p.x).toBeLessThan(1730);
+  });
+
   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);
+    expect(game.endReason).toBe('time');
+  });
+
+  it('the round ends early when almost no cake is left above the custard', () => {
+    const game = newRound();
+    run(game, 2 * TICK_RATE);
+    expect(game.roundOver).toBe(false);
+    const { data } = game.terrain;
+    let kept = 0;
+    for (let i = 0; i < data.length; i++) {
+      if (data[i] === 0 || data[i] === 255) continue;
+      if (kept < MIN_CAKE_PIXELS / 2) kept++;
+      else data[i] = 0;
+    }
+    run(game, 2 * TICK_RATE);
+    expect(game.roundOver).toBe(true);
+    expect(game.endReason).toBe('noCake');
+  });
+});
+
+describe('fair fights', () => {
+  it('a stun cannot be chained: after one, the player is immune for a moment', () => {
+    const game = newRound();
+    const mamie = game.addPlayer('Mamie', 'mamie');
+    const victim = game.addPlayer('Victime', 'generic');
+    run(game, 90);
+    let stunnedTicks = 0;
+    let longest = 0;
+    let streak = 0;
+    // Mamie jabs as fast as she can for 4 s, the victim held right in front of her.
+    for (let i = 0; i < 4 * TICK_RATE; i++) {
+      const m = me(game, mamie.id);
+      game.teleport(victim.id, m.x + 36, m.y);
+      game.setInput(mamie.id, { buttons: Button.Attack, aimX: Math.round(m.x + 36), aimY: Math.round(m.y) });
+      game.step();
+      const stunned = (me(game, victim.id).anim & Anim.Stunned) !== 0;
+      if (stunned) stunnedTicks++;
+      streak = stunned ? streak + 1 : 0;
+      longest = Math.max(longest, streak);
+    }
+    const stun = loadCharacterStats(ASSETS, 'mamie').params.needleStun;
+    expect(longest).toBeLessThanOrEqual(Math.ceil(stun * TICK_RATE) + 1);
+    // Free for at least the guard time after each stun.
+    expect(stunnedTicks / (4 * TICK_RATE)).toBeLessThan(stun / (stun + STUN_GUARD_S) + 0.1);
+  });
+
+  it('a stuck player can give up: into the custard, back with the spoon', () => {
+    const game = newRound();
+    const { id } = game.addPlayer('Coincé', 'generic');
+    run(game, 60);
+    game.setInput(id, { buttons: Button.Suicide, aimX: 0, aimY: 0 });
+    const fx = run(game, 1);
+    game.setInput(id, { buttons: 0, aimX: 0, aimY: 0 });
+    expect(fx).toContainEqual(expect.objectContaining({ kind: 'splash', id }));
+    expect(me(game, id).anim & Anim.Dead).toBeTruthy();
+    run(game, (RESPAWN_DELAY_S + 0.5) * TICK_RATE);
+    expect(me(game, id).anim & Anim.Dead).toBe(0);
   });
 });