Просмотр исходного кода

feat(characters): add Gégé, Bébé Pompier, Captain Prout, Le Chat and Magicien Raté

- Gégé swims through the cake and bursts out under players; takes less
  knockback buried, more in the open.
- Bébé Pompier: pushing water jet with recoil; soaking turns sponge into
  soaked sponge, which sags over time (server soak zones).
- Captain Prout: jetpack thrusts that leave toxic clouds (blurred vision,
  slowdown, coughing for other players).
- Le Chat: shoves chunks, items and stunned players; randomly stops to
  lick his paw (input ignored).
- Magicien Raté: weighted random spell (jelly, swap, giant rabbit, pfft),
  fizzling more when leading the round.
- Character context gains random/isSolid/standing/shove/cloud/swap/cue;
  new 'cue' fx event and round clouds. Protocol bumped to v9.
- Client: painters for the five, cue popups and particles, cloud view,
  poison blur, French texts; the menu hides the test character.

No new dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Idleman 1 неделя назад
Родитель
Сommit
4f86a6c17a

+ 31 - 0
assets/characters/chat/character.json

@@ -0,0 +1,31 @@
+{
+  "id": "chat",
+  "name": "Le Chat",
+  "speed": 285,
+  "acceleration": 2600,
+  "jump": 640,
+  "mass": 0.9,
+  "size": 18,
+  "cooldowns": {
+    "attack": 0.3,
+    "special": 1.0
+  },
+  "params": {
+    "pawReach": 12,
+    "pawRadius": 16,
+    "pawKnockback": 480,
+    "pawStun": 0.3,
+    "pawCarve": 12,
+    "pawPower": 30,
+    "pawAnim": 0.15,
+    "shoveReach": 60,
+    "shoveRadius": 48,
+    "shoveSpeed": 700,
+    "shoveLift": 220,
+    "shoveFraction": 0.35,
+    "shoveAnim": 0.25,
+    "lickMin": 7,
+    "lickMax": 14,
+    "lickTime": 1
+  }
+}

+ 34 - 0
assets/characters/gege/character.json

@@ -0,0 +1,34 @@
+{
+  "id": "gege",
+  "name": "Gégé le Ver",
+  "speed": 165,
+  "acceleration": 1500,
+  "jump": 520,
+  "mass": 0.9,
+  "size": 17,
+  "cooldowns": {
+    "attack": 0.4,
+    "special": 0.05
+  },
+  "params": {
+    "headReach": 10,
+    "headRadius": 16,
+    "headKnockback": 560,
+    "headStun": 0.45,
+    "headCarve": 10,
+    "headPower": 30,
+    "headAnim": 0.18,
+    "swimSpeed": 210,
+    "swimGrace": 0.18,
+    "swimMinPixels": 20,
+    "swimPower": 45,
+    "digInterval": 0.05,
+    "tunnelFactor": 1.15,
+    "emergeRadiusFactor": 2.2,
+    "emergeKnockback": 820,
+    "emergeStun": 0.8,
+    "emergeHop": 380,
+    "exposedKnockback": 1.5,
+    "buriedKnockback": 0.4
+  }
+}

+ 33 - 0
assets/characters/magicien/character.json

@@ -0,0 +1,33 @@
+{
+  "id": "magicien",
+  "name": "Magicien Raté",
+  "speed": 215,
+  "acceleration": 1800,
+  "jump": 570,
+  "mass": 1,
+  "size": 19,
+  "cooldowns": {
+    "attack": 0.4,
+    "special": 2.2
+  },
+  "params": {
+    "wandReach": 12,
+    "wandRadius": 14,
+    "wandKnockback": 420,
+    "wandStun": 0.3,
+    "wandCarve": 8,
+    "wandPower": 25,
+    "wandAnim": 0.15,
+    "castAnim": 0.4,
+    "spellReach": 260,
+    "jellyRadius": 70,
+    "rabbitHeight": 240,
+    "rabbitMinY": 90,
+    "rabbitScale": 1,
+    "weightJelly": 3,
+    "weightSwap": 2,
+    "weightRabbit": 2,
+    "weightPfft": 1,
+    "failPerStanding": 6
+  }
+}

+ 22 - 0
assets/characters/pompier/character.json

@@ -0,0 +1,22 @@
+{
+  "id": "pompier",
+  "name": "Bébé Pompier",
+  "speed": 220,
+  "acceleration": 1900,
+  "jump": 560,
+  "mass": 0.9,
+  "size": 17,
+  "cooldowns": {
+    "attack": 0.5,
+    "special": 0.9
+  },
+  "params": {
+    "jetReach": 150,
+    "jetPush": 620,
+    "jetLift": 120,
+    "recoil": 520,
+    "sprayAnim": 0.3,
+    "soakReach": 140,
+    "soakRadius": 46
+  }
+}

+ 27 - 0
assets/characters/prout/character.json

@@ -0,0 +1,27 @@
+{
+  "id": "prout",
+  "name": "Captain Prout",
+  "speed": 235,
+  "acceleration": 2000,
+  "jump": 560,
+  "mass": 1,
+  "size": 19,
+  "cooldowns": {
+    "attack": 0.4,
+    "special": 0.38
+  },
+  "params": {
+    "capeReach": 12,
+    "capeRadius": 20,
+    "capeKnockback": 620,
+    "capeStun": 0.35,
+    "capeCarve": 12,
+    "capePower": 30,
+    "capeAnim": 0.2,
+    "puffLift": 430,
+    "puffSide": 120,
+    "cloudRadius": 46,
+    "cloudLife": 3.5,
+    "puffAnim": 0.25
+  }
+}

+ 8 - 1
readme.md

@@ -333,7 +333,14 @@ Chaque étape doit être **jouable** avant de passer à la suivante.
   - **Rattrapage complet :** en fin de manche, le dernier choisit la carte et impose un personnage au premier, pour une manche.
   - **Trophées :** depuis M4.
   - **Les 3 cartes :** le Roulé (spirale de confiture ; le roulé ne tient que par son socle en biscuit), la Tarte aux Fruits (fond en biscuit cassant, `random-cracks`) et la Pile de Crêpes (fines couches, pile qui penche, `wind`).
-- **Reste :** les 5 personnages (Gégé le Ver, Bébé Pompier, Captain Prout, Le Chat, Magicien Raté), la DA finale, l'audio, l'éditeur de cartes et l'installeur Windows.
+  - **Les 5 personnages :**
+    - **Gégé le Ver :** spécial maintenu, il nage vers la visée en creusant une galerie étroite (le chocolat l'arrête). En sortant du gâteau, ou en lâchant le bouton sous terre, il surgit et éjecte qui se tient au-dessus. Il encaisse moins de recul enterré, plus à découvert.
+    - **Bébé Pompier :** le jet pousse tout ce qu'il touche (joueurs compris) et le recul le renvoie en arrière. Le détrempage change la génoise en génoise détrempée, qui s'affaisse par petits bouts pendant `SOAK_SAG_TIME_S`.
+    - **Captain Prout :** chaque poussée le soulève, s'affiche pour tout le monde et laisse un nuage toxique. Les autres joueurs dedans ont la vue floue, ralentissent et toussent (la course trébuche). Il est immunisé contre ses propres nuages.
+    - **Le Chat :** sa poussée déplace blocs, objets et joueurs assommés à pleine force (les autres un peu). Il s'arrête au hasard pour se lécher la patte et ignore alors les commandes.
+    - **Magicien Raté :** gelée (génoise, biscuit et chantilly deviennent gelée), échange de place avec un joueur au hasard, lapin géant en génoise qui tombe du ciel, ou « pfft ». Le poids du « pfft » augmente avec son classement dans la manche.
+    - Les signatures (prout, jet, sortie de terre, sorts, léchouille) passent par un événement `cue` commun, prêt pour l'audio.
+- **Reste :** la DA finale, l'audio, l'éditeur de cartes et l'installeur Windows.
 ---
  
 ## 10. Pour Claude : comment travailler sur ce projet

+ 139 - 0
src/renderer/characters/painters.ts

@@ -12,6 +12,10 @@ export interface Pose {
   attack: boolean;
   special: boolean;
   stunned: boolean;
+  /** Busy with something else (the cat licking its paw). */
+  busy: boolean;
+  /** Underground (Gégé swimming in the cake). */
+  underground: boolean;
   /** Unit look direction in local space. */
   lookX: number;
   lookY: number;
@@ -280,9 +284,144 @@ export const paintPyromanchot: Painter = (g, r, pose) => {
   stars(g, r, pose);
 };
 
+
+/** Gégé le Ver: a pink earthworm in segments with a headband; underground, only a mound and his eyes. */
+export const paintGege: Painter = (g, r, pose) => {
+  const pink = 0xff9ab0;
+  const dark = 0xe06a88;
+  if (pose.underground) {
+    // A heap of crumbs moving along, two eyes peeking out.
+    g.ellipse(0, 0.55 * r, 1.1 * r, 0.5 * r).fill(0xc8964b).stroke({ width: pose.ink, color: INK });
+    for (let i = 0; i < 5; i++) g.circle(-0.7 * r + i * 0.35 * r, 0.3 * r + (i % 2) * 0.1 * r, 0.12 * r).fill(0xf2c46d);
+    eyes(g, pose, [[0.1 * r, 0.05 * r], [0.45 * r, 0.07 * r]], 0.17 * r);
+    return;
+  }
+  const wiggle = pose.moving ? Math.sin(pose.walk) * 0.12 * r : Math.sin(pose.time * 3) * 0.05 * r;
+  // Tail segments behind.
+  for (let i = 3; i >= 1; i--) {
+    const sr = r * (0.75 - i * 0.12);
+    g.circle(-i * 0.55 * r, 0.35 * r + (i % 2 ? wiggle : -wiggle), sr).fill(i % 2 ? pink : dark).stroke({ width: pose.ink, color: INK });
+  }
+  const lunge = pose.attack ? 0.25 * r : 0;
+  g.circle(lunge, 0, r).fill(pink).stroke({ width: pose.ink, color: INK });
+  for (let i = -1; i <= 1; i++) g.moveTo(lunge - 0.6 * r, i * 0.25 * r).lineTo(lunge - 0.85 * r, i * 0.3 * r).stroke({ width: pose.ink * 0.5, color: dark });
+  g.rect(lunge - 0.95 * r, -0.62 * r, 1.9 * r, 0.18 * r).fill(0x4dc4ff).stroke({ width: pose.ink * 0.6, color: INK });
+  eyes(g, pose, [[lunge + 0.25 * r, -0.2 * r], [lunge + 0.6 * r, -0.18 * r]], 0.18 * r);
+  g.moveTo(lunge + 0.3 * r, 0.3 * r).quadraticCurveTo(lunge + 0.5 * r, 0.45 * r, lunge + 0.7 * r, 0.28 * r).stroke({ width: pose.ink * 0.7, color: INK, cap: 'round' });
+  stars(g, r, pose);
+};
+
+/** Bébé Pompier: a baby in a red helmet and yellow coat, pacifier in mouth, hose in hand. */
+export const paintPompier: Painter = (g, r, pose) => {
+  const step = pose.moving && pose.grounded ? Math.sin(pose.walk) : 0;
+  foot(g, -0.35 * r - step * 0.18 * r, 0.93 * r, r, 0x5a3a22, pose.ink);
+  g.circle(0, 0, r).fill(0xffd27a).stroke({ width: pose.ink, color: INK });
+  g.ellipse(0.2 * r, -0.15 * r, 0.62 * r, 0.55 * r).fill(0xffdcc4);
+  // Helmet with a badge.
+  g.poly(ellipsePoly(0, -0.62 * r, 1.0 * r, 0.42 * r, 0)).fill(0xe0123a).stroke({ width: pose.ink, color: INK });
+  g.rect(-1.1 * r, -0.5 * r, 2.2 * r, 0.14 * r).fill(0xc00f30).stroke({ width: pose.ink * 0.6, color: INK });
+  g.circle(0.15 * r, -0.72 * r, 0.14 * r).fill(0xffe14d).stroke({ width: pose.ink * 0.5, color: INK });
+  eyes(g, pose, [[0.15 * r, -0.2 * r], [0.5 * r, -0.18 * r]], 0.15 * r);
+  // Pacifier.
+  g.circle(0.48 * r, 0.22 * r, 0.16 * r).fill(0x4dc4ff).stroke({ width: pose.ink * 0.6, color: INK });
+  g.circle(0.48 * r, 0.22 * r, 0.06 * r).fill(0xffffff);
+  // Hose nozzle, spraying when attacking.
+  g.roundRect(0.6 * r, 0.32 * r, 0.6 * r, 0.16 * r, 0.05 * r).fill(0x8899aa).stroke({ width: pose.ink * 0.6, color: INK });
+  if (pose.attack) {
+    for (let i = 0; i < 4; i++) {
+      const y = 0.4 * r + (i - 1.5) * 0.12 * r;
+      g.moveTo(1.25 * r, y).lineTo((1.9 + (i % 2) * 0.4) * r, y + (i - 1.5) * 0.1 * r).stroke({ width: pose.ink * 0.9, color: 0x4dc4ff, cap: 'round' });
+    }
+  }
+  foot(g, 0.35 * r + step * 0.18 * r, 0.93 * r, r, 0x5a3a22, pose.ink);
+  stars(g, r, pose);
+};
+
+/** Captain Prout: a superhero with a purple cape, a mask and a big P on the chest. */
+export const paintProut: Painter = (g, r, pose) => {
+  const step = pose.moving && pose.grounded ? Math.sin(pose.walk) : 0;
+  const flap = Math.sin(pose.time * 8) * 0.15 * r + (pose.special ? 0.3 * r : 0);
+  // Cape behind, flapping.
+  g.poly([-0.3 * r, -0.6 * r, -1.35 * r - flap, 0.5 * r, -1.1 * r - flap, 0.9 * r, 0.1 * r, 0.6 * r]).fill(0x8a3fc9).stroke({ width: pose.ink, color: INK });
+  foot(g, -0.35 * r - step * 0.18 * r, 0.93 * r, r, 0x2a2a3a, pose.ink);
+  g.circle(0, 0, r).fill(0x9be15d).stroke({ width: pose.ink, color: INK });
+  // Mask.
+  g.rect(-0.1 * r, -0.42 * r, 1.0 * r, 0.32 * r).fill(0x2a2a3a);
+  eyes(g, pose, [[0.25 * r, -0.26 * r], [0.62 * r, -0.24 * r]], 0.13 * r);
+  // Emblem.
+  g.circle(0.15 * r, 0.35 * r, 0.3 * r).fill(0xffe14d).stroke({ width: pose.ink * 0.7, color: INK });
+  g.moveTo(0.06 * r, 0.52 * r).lineTo(0.06 * r, 0.2 * r).quadraticCurveTo(0.32 * r, 0.2 * r, 0.06 * r, 0.38 * r).stroke({ width: pose.ink * 0.8, color: 0x8a3fc9 });
+  if (pose.attack) g.poly([0.6 * r, -0.3 * r, 1.6 * r, 0.1 * r, 0.7 * r, 0.4 * r]).fill(0x8a3fc9).stroke({ width: pose.ink * 0.8, color: INK });
+  foot(g, 0.35 * r + step * 0.18 * r, 0.93 * r, r, 0x2a2a3a, pose.ink);
+  stars(g, r, pose);
+};
+
+/** Le Chat: a ginger cat with pointy ears, whiskers and a curling tail; licks its paw when busy. */
+export const paintChat: Painter = (g, r, pose) => {
+  const ginger = 0xff9a2e;
+  const dark = 0xd9741a;
+  const step = pose.moving && pose.grounded ? Math.sin(pose.walk) : 0;
+  const curl = Math.sin(pose.time * 2.5) * 0.2;
+  g.moveTo(-0.8 * r, 0.4 * r).quadraticCurveTo(-1.5 * r, 0.2 * r, -1.3 * r - curl * r, -0.5 * r).stroke({ width: pose.ink * 3.2, color: INK, cap: 'round' });
+  g.moveTo(-0.8 * r, 0.4 * r).quadraticCurveTo(-1.5 * r, 0.2 * r, -1.3 * r - curl * r, -0.5 * r).stroke({ width: pose.ink * 2, color: ginger, cap: 'round' });
+  foot(g, -0.35 * r - step * 0.2 * r, 0.93 * r, r, dark, pose.ink);
+  for (const ex of [-0.45, 0.35]) {
+    g.poly([ex * r - 0.25 * r, -0.65 * r, ex * r, -1.2 * r, ex * r + 0.28 * r, -0.62 * r]).fill(ginger).stroke({ width: pose.ink, color: INK });
+    g.poly([ex * r - 0.12 * r, -0.7 * r, ex * r, -1.0 * r, ex * r + 0.14 * r, -0.68 * r]).fill(0xffb6c8);
+  }
+  g.circle(0, 0, r).fill(ginger).stroke({ width: pose.ink, color: INK });
+  for (let i = 0; i < 3; i++) g.moveTo(-0.6 * r + i * 0.25 * r, -0.85 * r).lineTo(-0.5 * r + i * 0.25 * r, -0.6 * r).stroke({ width: pose.ink * 0.6, color: dark });
+  g.ellipse(0.25 * r, 0.3 * r, 0.45 * r, 0.4 * r).fill(0xfff3e0);
+  if (pose.busy) {
+    // Eyes closed, paw up at the mouth.
+    g.moveTo(0.1 * r, -0.2 * r).lineTo(0.35 * r, -0.2 * r).moveTo(0.48 * r, -0.18 * r).lineTo(0.72 * r, -0.18 * r).stroke({ width: pose.ink * 0.8, color: INK });
+    g.circle(0.6 * r, 0.25 * r, 0.22 * r).fill(ginger).stroke({ width: pose.ink * 0.8, color: INK });
+    g.ellipse(0.55 * r, 0.12 * r, 0.08 * r, 0.1 * r).fill(0xff6f91);
+  } else {
+    eyes(g, pose, [[0.22 * r, -0.2 * r], [0.6 * r, -0.18 * r]], 0.15 * r);
+    g.poly([0.62 * r, 0.02 * r, 0.74 * r, 0.02 * r, 0.68 * r, 0.1 * r]).fill(0xff6f91);
+  }
+  g.moveTo(0.8 * r, 0.08 * r).lineTo(1.3 * r, 0).moveTo(0.8 * r, 0.14 * r).lineTo(1.3 * r, 0.2 * r).stroke({ width: pose.ink * 0.4, color: INK });
+  if (pose.attack || pose.special) g.circle(1.05 * r, 0.1 * r, 0.24 * r).fill(ginger).stroke({ width: pose.ink * 0.8, color: INK });
+  foot(g, 0.35 * r + step * 0.2 * r, 0.93 * r, r, dark, pose.ink);
+  stars(g, r, pose);
+};
+
+/** Le Magicien Raté: blue starry robe, pointy hat, long white beard, a wand that sparkles on casting. */
+export const paintMagicien: Painter = (g, r, pose) => {
+  const step = pose.moving && pose.grounded ? Math.sin(pose.walk) : 0;
+  foot(g, -0.35 * r - step * 0.18 * r, 0.93 * r, r, 0x2a2a6a, pose.ink);
+  g.circle(0, 0, r).fill(0x3d5adb).stroke({ width: pose.ink, color: INK });
+  for (const [sx, sy] of [[-0.5, 0.2], [-0.2, 0.6], [-0.6, -0.3]]) g.circle(sx * r, sy * r, 0.07 * r).fill(0xffe14d);
+  g.ellipse(0.25 * r, -0.2 * r, 0.55 * r, 0.45 * r).fill(0xffdcc4);
+  // Hat.
+  const tilt = Math.sin(pose.time * 2) * 0.05 * r;
+  g.poly([-0.75 * r, -0.55 * r, 0.75 * r, -0.55 * r, 0.1 * r + tilt, -1.75 * r]).fill(0x2a2a6a).stroke({ width: pose.ink, color: INK });
+  g.circle(0.05 * r + tilt, -1.0 * r, 0.12 * r).fill(0xffe14d);
+  eyes(g, pose, [[0.15 * r, -0.28 * r], [0.48 * r, -0.26 * r]], 0.13 * r);
+  // Beard.
+  g.poly([0.0 * r, 0.0 * r, 0.75 * r, 0.0 * r, 0.42 * r, 0.85 * r]).fill(0xffffff).stroke({ width: pose.ink * 0.8, color: INK });
+  // Wand.
+  const raise = pose.special || pose.attack ? -0.6 : 0;
+  g.moveTo(0.7 * r, 0.25 * r).lineTo(1.35 * r, (0.05 + raise) * r).stroke({ width: pose.ink * 1.1, color: 0x5a3a22, cap: 'round' });
+  if (pose.special) {
+    for (let i = 0; i < 6; i++) {
+      const a = pose.time * 9 + (i * Math.PI) / 3;
+      g.circle(1.35 * r + Math.cos(a) * 0.3 * r, (0.05 + raise) * r + Math.sin(a) * 0.3 * r, 0.07 * r).fill(i % 2 ? 0xffe14d : 0xff6f91);
+    }
+  }
+  foot(g, 0.35 * r + step * 0.18 * r, 0.93 * r, r, 0x2a2a6a, pose.ink);
+  stars(g, r, pose);
+};
+
 export const PAINTERS: Record<string, Painter> = {
   castor: paintCastor,
   mamie: paintMamie,
   pyromanchot: paintPyromanchot,
+  gege: paintGege,
+  pompier: paintPompier,
+  prout: paintProut,
+  chat: paintChat,
+  magicien: paintMagicien,
   generic: paintGeneric,
 };

+ 2 - 0
src/renderer/config.ts

@@ -21,6 +21,8 @@ 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;

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

@@ -44,7 +44,7 @@ export class PlayerView {
 
   /** Draws a static pose (menu portraits). */
   drawIdle(): void {
-    this.draw(this.info.radius, 1, 0.6, -0.2, { moving: false, grounded: true, attack: false, special: false, stunned: false });
+    this.draw(this.info.radius, 1, 0.6, -0.2, { moving: false, grounded: true, attack: false, special: false, stunned: false, busy: false, underground: false });
   }
 
   update(state: PlayerState, aimX: number, aimY: number, dt: number): PlayerEvents {
@@ -87,6 +87,8 @@ export class PlayerView {
       attack: (state.anim & Anim.Attack) !== 0,
       special: (state.anim & Anim.Special) !== 0,
       stunned,
+      busy: (state.anim & Anim.Busy) !== 0,
+      underground: (state.anim & Anim.Underground) !== 0,
     });
     return events;
   }
@@ -96,7 +98,7 @@ export class PlayerView {
     facing: number,
     lookX: number,
     lookY: number,
-    flags: Pick<Pose, 'moving' | 'grounded' | 'attack' | 'special' | 'stunned'>,
+    flags: Pick<Pose, 'moving' | 'grounded' | 'attack' | 'special' | 'stunned' | 'busy' | 'underground'>,
   ): void {
     const base = this.info.radius;
     const scale = size / base;

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

@@ -157,6 +157,27 @@ interface Spoon {
 const SPOON_LIFE = 0.8;
 
 /** The spoon catapult flicking a player back onto the cake. */
+/** Captain Prout's toxic clouds: lumpy green puffs that fade as they thin out. */
+export class CloudsView {
+  readonly graphics = new Graphics();
+  private time = 0;
+
+  update(clouds: [number, number, number, number][], dt: number): void {
+    this.time += dt;
+    const g = this.graphics;
+    g.clear();
+    for (const [x, y, r, life] of clouds) {
+      const alpha = 0.25 + 0.4 * life;
+      for (let i = 0; i < 6; i++) {
+        const a = (i * Math.PI) / 3 + this.time * 0.6;
+        const br = r * (0.45 + 0.08 * Math.sin(this.time * 3 + i));
+        g.circle(x + Math.cos(a) * r * 0.45, y + Math.sin(a) * r * 0.35, br).fill({ color: i % 2 ? 0x9be15d : 0x7ac943, alpha });
+      }
+      g.circle(x, y, r * 0.5).fill({ color: 0xb8f07a, alpha });
+    }
+  }
+}
+
 export class SpoonsView {
   readonly container = new Container();
   private spoons: Spoon[] = [];

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

@@ -18,6 +18,11 @@ export const fr = {
     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',
   } as Record<string, string>,
   fps: (n: number) => `${n} FPS`,
   browser: {
@@ -69,7 +74,17 @@ export const fr = {
     explosion: ['BOUM !', 'KABOUM !', 'BADABOUM !'],
     whisk: ['BZZZT !', 'VRRRRR !'],
     drop: ['OUPS !', 'AÏE !', 'NOOON !'],
+    prout: ['PROUT !', 'PFRRT !', 'BRAAAP !', 'POUÊT !'],
+    water: ['PSCHHH !', 'SPLASH !', 'FLOTCH !'],
+    emerge: ['SURPRISE !', 'COUCOU !', 'TADAM !'],
+    lick: ['SLURP…', 'LAP LAP…', 'RONRON…'],
   },
+  spells: {
+    jelly: 'Gélifico !',
+    swap: 'Échangeus !',
+    rabbit: 'Lapinus Géantus !',
+    pfft: 'Pfft…',
+  } as Record<string, string>,
   results: {
     roundTitle: (n: number, total: number) => `Fin de la manche ${n}/${total}`,
     matchTitle: 'Le grand classement',
@@ -113,6 +128,41 @@ export const fr = {
       special: 'Glissade sur le ventre, explosion à l’arrivée',
       flaw: '1 pétard sur 3 lui explose dessus',
     },
+    gege: {
+      name: 'Gégé le Ver',
+      desc: 'Nage dans le gâteau et surgit sous les pieds.',
+      attack: 'Coup de tête',
+      special: 'Nage dans le décor (maintenu), surgit en éjectant qui est au-dessus',
+      flaw: 'Lent, et fragile à découvert',
+    },
+    pompier: {
+      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',
+      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',
+      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',
+      flaw: 'S’arrête au hasard pour se lécher la patte',
+    },
+    magicien: {
+      name: 'Magicien Raté',
+      desc: 'Ses sorts marchent. Parfois.',
+      attack: 'Coup de baguette',
+      special: 'Sort au hasard : gelée, échange, lapin géant ou « pfft »',
+      flaw: 'Plus il mène, plus ses sorts ratent',
+    },
     generic: {
       name: 'Le Testeur',
       desc: 'Court, saute, croque. Rien de plus.',

+ 44 - 5
src/renderer/main.ts

@@ -1,6 +1,6 @@
 import 'pixi.js/unsafe-eval'; // Pixi without eval, required by our Content-Security-Policy
-import { Application, CanvasSource, Container, ImageSource, Sprite, Texture } from 'pixi.js';
-import { Anim, CHARACTER_IDS, parseCharacterStats } from '../shared/characters';
+import { Application, BlurFilter, CanvasSource, Container, ImageSource, 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 { buildTerrain, parseMapDefinition } from '../shared/maps/loader';
 import { parseHexColor, parseMaterials, type MaterialRegistry } from '../shared/materials';
@@ -17,6 +17,7 @@ import {
   EXPLOSION_SHAKE,
   FREEZE_FRAME_MS,
   HIT_SHAKE,
+  POISON_BLUR,
   INPUT_SEND_HZ,
   LANDING_DUST_MIN_VY,
   MERGE_SHAKE,
@@ -31,7 +32,7 @@ import {
 } from './config';
 import { ChunkView } from './entities/ChunkView';
 import { PlayerView } from './entities/PlayerView';
-import { CherryView, CustardView, ItemsView, SpoonsView } from './entities/RoundViews';
+import { CherryView, CloudsView, CustardView, ItemsView, SpoonsView } from './entities/RoundViews';
 import { Particles } from './fx/Particles';
 import { Popups } from './fx/Popups';
 import { fr } from './i18n/fr';
@@ -90,7 +91,7 @@ function makeVignette(): Sprite {
 /** Renders each character's idle pose to a canvas for the menu. */
 function makePortraits(app: Application): Map<string, HTMLCanvasElement> {
   const out = new Map<string, HTMLCanvasElement>();
-  CHARACTER_IDS.forEach((characterId, i) => {
+  PLAYABLE_CHARACTER_IDS.forEach((characterId, i) => {
     const view = new PlayerView({ id: i + 1, name: '', characterId, radius: PORTRAIT_RADIUS });
     view.drawIdle();
     out.set(characterId, app.renderer.extract.canvas({ target: view.container, resolution: 2 }) as HTMLCanvasElement);
@@ -104,7 +105,7 @@ async function chooseGame(app: Application): Promise<{ choice: MenuChoice; addre
   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(CHARACTER_IDS, DEFAULT_CHARACTER_ID, portraits, stats);
+    const choice = await showMenu(PLAYABLE_CHARACTER_IDS, DEFAULT_CHARACTER_ID, portraits, stats);
     const target = await showBrowser();
     if (target.mode === 'back') continue;
     if (target.mode === 'join') return { choice, address: target.address, port: target.port };
@@ -137,6 +138,9 @@ async function start(): Promise<void> {
   const cherryView = new CherryView();
   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(
@@ -144,6 +148,7 @@ async function start(): Promise<void> {
     itemsView.container,
     playersLayer,
     cherryView.graphics,
+    cloudsView.graphics,
     custardView.graphics,
     spoonsView.container,
     particles.graphics,
@@ -297,6 +302,33 @@ async function start(): Promise<void> {
       .catch((err: unknown) => setStatus(fr.loadError(err instanceof Error ? err.message : String(err))));
   };
 
+  /** 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;
+    switch (ev.cue) {
+      case 'prout':
+        particles.puff(ev.x, ev.y + size, 8, 0x9be15d);
+        popups.spawn(ev.x, ev.y + size + 10, fr.fxWords.prout, 34);
+        break;
+      case 'water':
+        particles.burst(ev.x, ev.y, 14, 0x4dc4ff, 520);
+        popups.spawn(ev.x, ev.y - size - 16, fr.fxWords.water, 26);
+        break;
+      case 'emerge':
+        particles.burst(ev.x, ev.y - size, 18, 0xc8964b, 480);
+        popups.spawn(ev.x, ev.y - size - 24, fr.fxWords.emerge, 40);
+        shake = Math.min(SHAKE_MAX, shake + HIT_SHAKE);
+        break;
+      case 'lick':
+        popups.spawn(ev.x, ev.y - size - 16, fr.fxWords.lick, 24);
+        break;
+      default:
+        particles.burst(ev.x, ev.y - size, 12, 0xffe14d, 300);
+        popups.spawn(ev.x, ev.y - size - 24, [fr.spells[ev.cue.slice('spell-'.length)] ?? ev.cue], 32);
+        break;
+    }
+  };
+
   /** One-shot cues from the server: splashes, spoons, explosions, pickups. */
   const onFx = (ev: FxEvent): void => {
     switch (ev.kind) {
@@ -322,6 +354,9 @@ async function start(): Promise<void> {
       case 'cherryDrop':
         popups.spawn(ev.x, ev.y - 40, fr.fxWords.drop, 32);
         break;
+      case 'cue':
+        onCue(ev);
+        break;
       case 'whisk':
         shake = Math.min(SHAKE_MAX, shake + WHISK_SHAKE);
         for (const s of states.values()) {
@@ -477,6 +512,7 @@ async function start(): Promise<void> {
     cherryView.update(sample.round?.cherry ?? null, dt);
     if (sample.round) custardView.update(sample.round.custardY, world.terrain.width, world.terrain.height, dt, sample.round.finalRush);
     spoonsView.update(dt);
+    cloudsView.update(sample.round?.clouds ?? [], dt);
     hud.update(round, rounds, sample.round, states, infos, localId);
     const [aimX, aimY] = screenToWorld(input.mouseX, input.mouseY);
     for (const [id, state] of states) {
@@ -506,6 +542,9 @@ async function start(): Promise<void> {
     const scale = app.screen.height / CAMERA_VIEW_HEIGHT;
     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] : [];
     const viewW = app.screen.width / scale;
     const viewH = app.screen.height / scale;
     if (me) {

+ 143 - 6
src/server/game.ts

@@ -26,6 +26,8 @@ import {
   CHUNK_REST_ANGULAR,
   CHUNK_REST_SPEED,
   CHUNK_REST_TIME,
+  COUGH_INTERVAL_S,
+  COUGH_STUMBLE,
   COYOTE_TIME,
   CRUSH_IMPULSE,
   CRUSH_MIN_SPEED,
@@ -37,15 +39,22 @@ import {
   ITEM_RADIUS,
   JUMP_BUFFER_TIME,
   JUMP_CUT,
+  MATERIAL_SOAKED,
   MATERIAL_WOOL,
   MIN_CHUNK_PIXELS,
   OUT_OF_MAP_MARGIN,
   PIXELS_PER_METER,
+  POISON_S,
+  POISON_SPEED_MULT,
   PROJECTILE_ARM_S,
   RESIZE_THRESHOLD_PX,
   RESPAWN_DELAY_S,
   SMARTIES_DURATION_S,
   SMARTIES_SPEED_MULT,
+  SOAK_SAG_INTERVAL_S,
+  SOAK_SAG_POWER,
+  SOAK_SAG_RADIUS,
+  SOAK_SAG_TIME_S,
   SPOON_LAUNCH_SPEED,
   TICK_DT,
   WHISK_CRACK_POWER,
@@ -105,6 +114,9 @@ interface ServerPlayer {
   boost: number;
   /** Spoon flight time left: horizontal momentum is kept unless the player steers. */
   flight: number;
+  /** Toxic cloud: seconds of poisoning left, and time to the next cough. */
+  poison: number;
+  cough: number;
   item: ItemKind | null;
   /** Seconds holding the cherry this round. */
   score: number;
@@ -227,6 +239,9 @@ export class Game {
   private readonly modifierCtx: ModifierContext;
   /** Side wind this tick (px/s²). */
   private wind = 0;
+  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 }[] = [];
 
   constructor(opts: GameOptions) {
     this.map = opts.map;
@@ -316,6 +331,8 @@ export class Game {
       dead: 0,
       boost: 0,
       flight: 0,
+      poison: 0,
+      cough: 0,
       item: null,
       score: 0,
     };
@@ -380,6 +397,7 @@ export class Game {
     const changedBy: number[] = [];
     for (const { op, owner, onApplied } of pending) {
       const { changed, removedByMaterial } = this.emit({ kind: 'op', op }, result);
+      if (changed && op.kind === 'convert' && op.to === MATERIAL_SOAKED) this.soakZones.push({ x: op.x, y: op.y, r: op.r, time: SOAK_SAG_TIME_S, next: 0 });
       let removed = 0;
       for (const n of removedByMaterial.values()) removed += n;
       this.stats.add(owner, 'eaten', removed);
@@ -405,6 +423,8 @@ export class Game {
     this.updateRound();
     this.updateCherry();
     this.updateItems();
+    this.updateClouds();
+    this.updateSoakZones();
     this.updateChunks(result);
     this.syncTerrain(result);
     result.fx = this.fx;
@@ -427,7 +447,11 @@ export class Game {
         grounded: p.grounded,
         size: p.character.size,
         anim:
-          p.character.anim() | (p.stun > 0 ? Anim.Stunned : 0) | (p.dead > 0 ? Anim.Dead : 0) | (p.boost > 0 ? Anim.Boosted : 0),
+          p.character.anim() |
+          (p.stun > 0 ? Anim.Stunned : 0) |
+          (p.dead > 0 ? Anim.Dead : 0) |
+          (p.boost > 0 ? Anim.Boosted : 0) |
+          (p.poison > 0 ? Anim.Poisoned : 0),
         score: Math.floor(p.score),
         item: p.item,
       };
@@ -455,6 +479,7 @@ export class Game {
       timeLeft: this.rules ? Math.max(0, this.timeLeft) : null,
       finalRush: this.rules !== null && this.timeLeft <= FINAL_RUSH_S,
       wind: this.wind,
+      clouds: this.clouds.map((c) => [c.x, c.y, c.r, c.life / c.maxLife]),
       cherry,
     };
   }
@@ -596,8 +621,10 @@ export class Game {
     p.stun = Math.max(0, p.stun - TICK_DT);
     p.boost = Math.max(0, p.boost - TICK_DT);
     p.flight = Math.max(0, p.flight - TICK_DT);
-    // Stunned players keep their momentum but ignore input.
-    const input = p.stun > 0 ? { ...p.input, buttons: 0 } : p.input;
+    p.poison = Math.max(0, p.poison - TICK_DT);
+    // Stunned (or busy) players keep their momentum but ignore input.
+    const locked = p.stun > 0 || p.character.busy();
+    const input = locked ? { ...p.input, buttons: 0 } : p.input;
     const pressed = input.buttons & ~p.prevButtons;
     const released = p.prevButtons & ~input.buttons;
     p.prevButtons = input.buttons;
@@ -606,9 +633,9 @@ 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;
+    const speedMult = (p.boost > 0 ? SMARTIES_SPEED_MULT : 1) * (p.poison > 0 ? POISON_SPEED_MULT : 1);
 
-    if (p.stun === 0) {
+    if (!locked) {
       const dir = (isDown(input, Button.Right) ? 1 : 0) - (isDown(input, Button.Left) ? 1 : 0);
       const accel = stats.acceleration * speedMult * (p.grounded ? 1 : AIR_CONTROL) * TICK_DT;
       const target = dir * stats.speed * mobility * speedMult;
@@ -626,6 +653,14 @@ export class Game {
     }
     // Wind: full push in the air, partial on the ground.
     vx += this.wind * (p.grounded ? WIND_GROUND_FACTOR : 1) * TICK_DT;
+    // Coughing in a toxic cloud: the run stumbles now and then.
+    if (p.poison > 0) {
+      p.cough -= TICK_DT;
+      if (p.cough <= 0) {
+        p.cough = COUGH_INTERVAL_S * (0.6 + this.rng.next() * 0.8);
+        vx *= COUGH_STUMBLE;
+      }
+    }
     p.body.setLinearVelocity({ x: vx / PPM, y: vy / PPM });
 
     if (pressed & Button.Use && p.item === 'candle') this.throwCandle(p, input);
@@ -743,7 +778,8 @@ 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);
-      target.body.setLinearVelocity({ x: ((nx / len) * knockback) / PPM, y: ((ny / len) * knockback) / PPM });
+      const kb = knockback * target.character.knockbackTaken();
+      target.body.setLinearVelocity({ x: ((nx / len) * kb) / PPM, y: ((ny / len) * kb) / PPM });
       target.stun = Math.max(target.stun, stun);
       target.character.onHit(this.contextFor(target, target.input));
       // A hit holder lets the cherry go (readme §4).
@@ -772,7 +808,108 @@ export class Game {
       hit: (x, y, r, knockback, stun) => this.hitArea(p, x, y, r, knockback, stun),
       launch: (spec, x, y, vx, vy) => this.throwEntity(spec.kind, spec, p.info.id, x, y, vx, vy),
       explode: (x, y, blast, spareSelf) => this.explodeAt(x, y, blast, p.info.id, spareSelf ? p : null),
+      random: () => this.rng.next(),
+      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),
+      cloud: (x, y, r, life) => this.clouds.push({ x, y, r, life, maxLife: life, owner: p.info.id }),
+      swapWithRandomPlayer: () => this.swapWithRandom(p),
+      cue: (cue) => {
+        const at = p.body.getPosition();
+        this.fx.push({ kind: 'cue', id: p.info.id, cue, x: at.x * PPM, y: at.y * PPM });
+      },
+    };
+  }
+
+  /** Rank by this round's score: 0 = last, 1 = leader, 0.5 when alone or everyone is tied. */
+  private standing(p: ServerPlayer): number {
+    const others = [...this.players.values()].filter((o) => o !== p);
+    if (others.length === 0) return 0.5;
+    let below = 0;
+    let tied = 0;
+    for (const o of others) {
+      if (o.score < p.score) below++;
+      else if (o.score === p.score) tied++;
+    }
+    return (below + tied / 2) / others.length;
+  }
+
+  /** Pushes falling chunks, loose items and stunned players around (x, y); others get `fraction` of it. */
+  private shove(by: ServerPlayer, x: number, y: number, r: number, vx: number, vy: number, fraction: number): number {
+    let moved = 0;
+    const near = (bx: number, by2: number, extra: number): boolean => Math.hypot(bx * PPM - x, by2 * PPM - y) <= r + extra;
+    const push = (body: Body, k: number): void => {
+      const v = body.getLinearVelocity();
+      body.setLinearVelocity({ x: v.x + (vx * k) / PPM, y: v.y + (vy * k) / PPM });
+      body.setAwake(true);
+      moved++;
     };
+    for (const c of this.chunks.values()) {
+      const center = c.body.getWorldCenter();
+      if (near(center.x, center.y, Math.max(c.w, c.h) / 2)) push(c.body, 1);
+    }
+    for (const it of this.items.values()) {
+      const pos = it.body.getPosition();
+      if (near(pos.x, pos.y, ITEM_RADIUS)) push(it.body, 1);
+    }
+    for (const t of this.alivePlayers()) {
+      if (t === by) continue;
+      const pos = t.body.getPosition();
+      if (!near(pos.x, pos.y, t.bodyRadius)) continue;
+      push(t.body, t.stun > 0 ? 1 : fraction);
+      if (this.cherry?.holder === t.info.id && t.stun > 0) this.dropCherry(t);
+    }
+    return moved;
+  }
+
+  /** Swaps places (and momentum) with another living player chosen at random. */
+  private swapWithRandom(p: ServerPlayer): boolean {
+    const others = this.alivePlayers().filter((o) => o !== p);
+    if (others.length === 0) return false;
+    const o = others[this.rng.int(0, others.length - 1)];
+    const a = p.body.getPosition().clone();
+    const b = o.body.getPosition().clone();
+    p.body.setPosition(b);
+    o.body.setPosition(a);
+    p.body.setLinearVelocity({ x: 0, y: 0 });
+    o.body.setLinearVelocity({ x: 0, y: 0 });
+    p.body.setAwake(true);
+    o.body.setAwake(true);
+    return true;
+  }
+
+  private updateClouds(): void {
+    if (this.clouds.length === 0) return;
+    for (const c of this.clouds) {
+      c.life -= TICK_DT;
+      for (const p of this.alivePlayers()) {
+        if (p.info.id === c.owner) continue; // he is used to it
+        const pos = p.body.getPosition();
+        if (Math.hypot(pos.x * PPM - c.x, pos.y * PPM - c.y) <= c.r + p.bodyRadius) p.poison = POISON_S;
+      }
+    }
+    this.clouds = this.clouds.filter((c) => c.life > 0);
+  }
+
+  /** Soaked sponge slowly sags: a few soaked pixels in recently soaked areas crumble (readme §4). */
+  private updateSoakZones(): void {
+    if (this.soakZones.length === 0) return;
+    const { width, height, data } = this.terrain;
+    for (const z of this.soakZones) {
+      z.time -= TICK_DT;
+      z.next -= TICK_DT;
+      if (z.next > 0) continue;
+      z.next = SOAK_SAG_INTERVAL_S;
+      // 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));
+        const y = Math.round(z.y + this.rng.range(0, z.r));
+        if (x < 0 || y < 0 || x >= width || y >= height || data[y * width + x] !== MATERIAL_SOAKED) continue;
+        this.queueOp({ kind: 'carve', x, y, r: SOAK_SAG_RADIUS, power: SOAK_SAG_POWER });
+        break;
+      }
+    }
+    this.soakZones = this.soakZones.filter((z) => z.time > 0);
   }
 
   private alivePlayers(): ServerPlayer[] {

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

@@ -29,8 +29,17 @@ export const Anim = {
   Dead: 1 << 3,
   /** Smarties speed boost. */
   Boosted: 1 << 4,
+  /** In a toxic cloud: blurred vision, coughing. */
+  Poisoned: 1 << 5,
+  /** Busy with something else (the cat licking its paw): ignores input. */
+  Busy: 1 << 6,
+  /** Underground (Gégé swimming in the cake): drawn as a bump. */
+  Underground: 1 << 7,
 } as const;
 
+/** A random spell of the Magicien Raté. */
+export type Spell = 'jelly' | 'swap' | 'rabbit' | 'pfft';
+
 /** An explosion: carves the cake (power vs hardness), burns wool around it, blows players away. */
 export interface Blast {
   r: number;
@@ -67,8 +76,27 @@ export interface CharacterContext {
   launch(spec: ProjectileSpec, x: number, y: number, vx: number, vy: number): void;
   /** Explodes at (x, y). `spareSelf`: the character is not blown away by its own blast. */
   explode(x: number, y: number, blast: Blast, spareSelf: boolean): void;
+  /** Seeded random in [0, 1): the only randomness characters may use (readme §3). */
+  random(): number;
+  /** Whether the terrain is solid at (x, y). */
+  isSolid(x: number, y: number): boolean;
+  /** Rank in the round: 0 = last, 1 = leader (0.5 when alone or all tied). */
+  readonly standing: number;
+  /**
+   * Pushes, around the disc (x, y, r), falling chunks, loose items and stunned players by
+   * (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;
+  /** 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. */
+  swapWithRandomPlayer(): boolean;
+  /** A visual/sound cue everyone notices (e.g. "PROUT !", a spell). */
+  cue(kind: CharacterCue): void;
 }
 
+export type CharacterCue = 'prout' | 'water' | `spell-${Spell}` | 'emerge' | 'lick';
+
 /** Base class for all characters. Pure logic: no physics engine, no rendering. */
 export abstract class Character {
   /** Current body radius (px). */
@@ -95,6 +123,16 @@ export abstract class Character {
   mobility(): number {
     return 1;
   }
+
+  /** Multiplier on the knockback this character takes (Gégé is fragile in the open). */
+  knockbackTaken(): number {
+    return 1;
+  }
+
+  /** True while the character ignores input (the cat busy licking its paw). */
+  busy(): boolean {
+    return false;
+  }
 }
 
 export function parseCharacterStats(json: unknown): CharacterStats {

+ 60 - 0
src/shared/characters/Chat.ts

@@ -0,0 +1,60 @@
+import { Anim, Character, type CharacterContext } from './Character';
+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.
+ */
+export class Chat extends Character {
+  private pawTimer = 0;
+  private shoveTimer = 0;
+  private licking = 0;
+  /** Seconds before the next lick, drawn on the first tick. */
+  private nextLick = -1;
+
+  onAttack(ctx: CharacterContext): void {
+    const p = this.stats.params;
+    const [dx, dy] = aimDir(ctx);
+    const reach = this.size + p.pawReach;
+    const px = ctx.x + dx * reach;
+    const py = ctx.y + dy * reach;
+    ctx.hit(px, py, p.pawRadius, p.pawKnockback, p.pawStun);
+    ctx.emitOp({ kind: 'carve', x: Math.round(px), y: Math.round(py), r: Math.round(p.pawCarve), power: Math.round(p.pawPower) });
+    this.pawTimer = p.pawAnim;
+  }
+
+  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;
+  }
+
+  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.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;
+    }
+    this.nextLick -= dt;
+    if (this.nextLick <= 0) {
+      this.licking = p.lickTime;
+      this.nextLick = p.lickMin + ctx.random() * (p.lickMax - p.lickMin);
+      ctx.cue('lick');
+    }
+  }
+
+  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);
+  }
+}

+ 92 - 0
src/shared/characters/Gege.ts

@@ -0,0 +1,92 @@
+import { Button, isDown } from '../input';
+import { Anim, Character, type CharacterContext } from './Character';
+import { aimDir } from './Mamie';
+
+/**
+ * Gégé le Ver.
+ * - Attack: headbutt.
+ * - Special (held): swims through the cake toward the aim in a narrow tunnel. Breaking out of
+ *   the terrain (or letting go while underground), he bursts up and sends flying whoever stands
+ *   right above.
+ * - Flaw: slow, and fragile in the open (takes extra knockback unless underground).
+ */
+export class Gege extends Character {
+  private headTimer = 0;
+  private digTimer = 0;
+  /** Seconds of swimming thrust left: renewed while there is cake around him. */
+  private swimTime = 0;
+  private underground = false;
+  private swimming = false;
+  /** Pixels eaten by the last dig, to notice he broke out into the open. */
+  private lastDug = 0;
+
+  onAttack(ctx: CharacterContext): void {
+    const p = this.stats.params;
+    const [dx, dy] = aimDir(ctx);
+    const reach = this.size + p.headReach;
+    const hx = ctx.x + dx * reach;
+    const hy = ctx.y + dy * reach;
+    ctx.hit(hx, hy, p.headRadius, p.headKnockback, p.headStun);
+    ctx.emitOp({ kind: 'carve', x: Math.round(hx), y: Math.round(hy), r: Math.round(p.headCarve), power: Math.round(p.headPower) });
+    this.headTimer = p.headAnim;
+  }
+
+  onSpecial(_ctx: CharacterContext): void {}
+
+  override onTick(ctx: CharacterContext, dt: number): void {
+    const p = this.stats.params;
+    this.headTimer = Math.max(0, this.headTimer - dt);
+    this.swimTime = Math.max(0, this.swimTime - dt);
+    this.swimming = isDown(ctx.input, Button.Special);
+
+    if (!this.swimming) {
+      if (this.underground) this.emerge(ctx);
+      this.digTimer = 0;
+      return;
+    }
+    const [dx, dy] = aimDir(ctx);
+    if (this.swimTime > 0) ctx.setVelocity(dx * p.swimSpeed, dy * p.swimSpeed);
+    this.digTimer -= dt;
+    if (this.digTimer > 0) return;
+    this.digTimer = p.digInterval;
+    const front = this.size * 0.5;
+    // Nothing left to eat ahead: either he broke out into the open (burst up), or he is
+    // blocked by something too hard for him (he just stops).
+    if (this.underground && this.lastDug < p.swimMinPixels) {
+      const ahead = this.size + 4;
+      if (!ctx.isSolid(ctx.x + dx * ahead, ctx.y + dy * ahead)) {
+        this.emerge(ctx);
+        return;
+      }
+    }
+    ctx.emitOp(
+      { kind: 'carve', x: Math.round(ctx.x + dx * front), y: Math.round(ctx.y + dy * front), r: Math.round(this.size * p.tunnelFactor), power: Math.round(p.swimPower) },
+      (n) => {
+        this.lastDug = n;
+        if (n >= p.swimMinPixels) {
+          this.swimTime = p.swimGrace;
+          this.underground = true;
+        }
+      },
+    );
+  }
+
+  /** Bursts out of the cake: whoever stands above him goes flying. */
+  private emerge(ctx: CharacterContext): void {
+    const p = this.stats.params;
+    this.underground = false;
+    this.lastDug = 0;
+    this.swimTime = 0;
+    ctx.hit(ctx.x, ctx.y - this.size, this.size * p.emergeRadiusFactor, p.emergeKnockback, p.emergeStun);
+    ctx.setVelocity(ctx.vx * 0.5, -p.emergeHop);
+    ctx.cue('emerge');
+  }
+
+  override anim(): number {
+    return (this.headTimer > 0 ? Anim.Attack : 0) | (this.swimming ? Anim.Special : 0) | (this.underground ? Anim.Underground : 0);
+  }
+
+  override knockbackTaken(): number {
+    return this.underground ? this.stats.params.buriedKnockback : this.stats.params.exposedKnockback;
+  }
+}

+ 101 - 0
src/shared/characters/Magicien.ts

@@ -0,0 +1,101 @@
+import { MATERIAL_BISCUIT, MATERIAL_CREAM, MATERIAL_JELLY, MATERIAL_SPONGE } from '../constants';
+import { Anim, Character, type CharacterContext, type Spell } from './Character';
+import { aimDir } from './Mamie';
+
+/**
+ * Le Magicien Raté.
+ * - Attack: wand tap.
+ * - Special: a random spell (seeded): the cake around the aim turns to jelly, he swaps places
+ *   with a random player, a giant sponge rabbit drops from the sky onto the aim, or... "pfft".
+ * - Flaw: weighted draw: the better he is doing, the more his spells fizzle.
+ */
+export class Magicien extends Character {
+  private wandTimer = 0;
+  private castTimer = 0;
+  /** Last spell cast (tests, debugging). */
+  lastSpell: Spell | null = null;
+
+  onAttack(ctx: CharacterContext): void {
+    const p = this.stats.params;
+    const [dx, dy] = aimDir(ctx);
+    const reach = this.size + p.wandReach;
+    const wx = ctx.x + dx * reach;
+    const wy = ctx.y + dy * reach;
+    ctx.hit(wx, wy, p.wandRadius, p.wandKnockback, p.wandStun);
+    ctx.emitOp({ kind: 'carve', x: Math.round(wx), y: Math.round(wy), r: Math.round(p.wandCarve), power: Math.round(p.wandPower) });
+    this.wandTimer = p.wandAnim;
+  }
+
+  onSpecial(ctx: CharacterContext): void {
+    const p = this.stats.params;
+    const spell = this.draw(ctx);
+    this.lastSpell = spell;
+    this.castTimer = p.castAnim;
+    // Aim point, clamped to the spell's reach.
+    let dx = ctx.input.aimX - ctx.x;
+    let dy = ctx.input.aimY - ctx.y;
+    const dist = Math.hypot(dx, dy);
+    if (dist > p.spellReach) {
+      dx = (dx / dist) * p.spellReach;
+      dy = (dy / dist) * p.spellReach;
+    }
+    const ax = Math.round(ctx.x + dx);
+    const ay = Math.round(ctx.y + dy);
+
+    if (spell === 'jelly') {
+      const r = Math.round(p.jellyRadius);
+      for (const from of [MATERIAL_SPONGE, MATERIAL_BISCUIT, MATERIAL_CREAM]) ctx.emitOp({ kind: 'convert', x: ax, y: ay, r, from, to: MATERIAL_JELLY });
+    } else if (spell === 'swap') {
+      if (!ctx.swapWithRandomPlayer()) {
+        this.lastSpell = 'pfft';
+        ctx.cue('spell-pfft');
+        return;
+      }
+    } else if (spell === 'rabbit') {
+      this.dropRabbit(ctx, ax, Math.max(p.rabbitMinY, ay - p.rabbitHeight));
+    }
+    ctx.cue(`spell-${this.lastSpell ?? spell}`);
+  }
+
+  /** Weighted draw; "pfft" weighs more the higher he stands in the round. */
+  private draw(ctx: CharacterContext): Spell {
+    const p = this.stats.params;
+    const weights: [Spell, number][] = [
+      ['jelly', p.weightJelly],
+      ['swap', p.weightSwap],
+      ['rabbit', p.weightRabbit],
+      ['pfft', p.weightPfft + ctx.standing * p.failPerStanding],
+    ];
+    const total = weights.reduce((sum, [, w]) => sum + w, 0);
+    let roll = ctx.random() * total;
+    for (const [spell, w] of weights) {
+      roll -= w;
+      if (roll < 0) return spell;
+    }
+    return 'pfft';
+  }
+
+  /** A giant sponge rabbit (body, head, ears) appears in the air above (x, top) and falls. */
+  private dropRabbit(ctx: CharacterContext, x: number, top: number): void {
+    const s = this.stats.params.rabbitScale;
+    const disc = (dx: number, dy: number, r: number): void =>
+      ctx.emitOp({ kind: 'fill', x: Math.round(x + dx * s), y: Math.round(top + dy * s), r: Math.round(r * s), material: MATERIAL_SPONGE });
+    disc(0, 60, 40); // body
+    disc(-30, 80, 24); // haunch
+    disc(34, 20, 26); // head
+    for (let i = 0; i < 4; i++) {
+      disc(24, -6 - i * 14, 9); // ear
+      disc(44, -6 - i * 14, 9); // ear
+    }
+    disc(-44, 46, 12); // tail
+  }
+
+  override onTick(_ctx: CharacterContext, dt: number): void {
+    this.wandTimer = Math.max(0, this.wandTimer - dt);
+    this.castTimer = Math.max(0, this.castTimer - dt);
+  }
+
+  override anim(): number {
+    return (this.wandTimer > 0 ? Anim.Attack : 0) | (this.castTimer > 0 ? Anim.Special : 0);
+  }
+}

+ 50 - 0
src/shared/characters/Pompier.ts

@@ -0,0 +1,50 @@
+import { MATERIAL_SOAKED, MATERIAL_SPONGE } from '../constants';
+import { Anim, Character, type CharacterContext } from './Character';
+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).
+ * - Flaw: the jet's recoil sends him flying backwards.
+ */
+export class Pompier extends Character {
+  private sprayTimer = 0;
+
+  onAttack(ctx: CharacterContext): void {
+    const p = this.stats.params;
+    const [dx, dy] = aimDir(ctx);
+    const mid = this.size + p.jetReach / 2;
+    const jx = ctx.x + dx * mid;
+    const jy = ctx.y + dy * mid;
+    // A jet pushes along its direction: everything in it, players included, at full strength.
+    ctx.shove(jx, jy, p.jetReach / 2, dx * p.jetPush, dy * p.jetPush - p.jetLift, 1);
+    // Recoil.
+    ctx.setVelocity(ctx.vx - dx * p.recoil, ctx.vy - dy * p.recoil);
+    ctx.cue('water');
+    this.sprayTimer = p.sprayAnim;
+  }
+
+  onSpecial(ctx: CharacterContext): void {
+    const p = this.stats.params;
+    let dx = ctx.input.aimX - ctx.x;
+    let dy = ctx.input.aimY - ctx.y;
+    const dist = Math.hypot(dx, dy);
+    if (dist > p.soakReach) {
+      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 });
+    ctx.cue('water');
+    this.sprayTimer = p.sprayAnim;
+  }
+
+  override onTick(_ctx: CharacterContext, dt: number): void {
+    this.sprayTimer = Math.max(0, this.sprayTimer - dt);
+  }
+
+  override anim(): number {
+    return this.sprayTimer > 0 ? Anim.Attack : 0;
+  }
+}

+ 43 - 0
src/shared/characters/Prout.ts

@@ -0,0 +1,43 @@
+import { Anim, Character, type CharacterContext } from './Character';
+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.
+ * - Flaw: every thrust is heard ("PROUT !" for everyone, giving away where he is).
+ */
+export class Prout extends Character {
+  private capeTimer = 0;
+  private puffTimer = 0;
+
+  onAttack(ctx: CharacterContext): void {
+    const p = this.stats.params;
+    const [dx, dy] = aimDir(ctx);
+    const reach = this.size + p.capeReach;
+    const cx = ctx.x + dx * reach;
+    const cy = ctx.y + dy * reach;
+    ctx.hit(cx, cy, p.capeRadius, p.capeKnockback, p.capeStun);
+    ctx.emitOp({ kind: 'carve', x: Math.round(cx), y: Math.round(cy), r: Math.round(p.capeCarve), power: Math.round(p.capePower) });
+    this.capeTimer = p.capeAnim;
+  }
+
+  onSpecial(ctx: CharacterContext): void {
+    const p = this.stats.params;
+    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);
+    ctx.cue('prout');
+    this.puffTimer = p.puffAnim;
+  }
+
+  override onTick(_ctx: CharacterContext, dt: number): void {
+    this.capeTimer = Math.max(0, this.capeTimer - dt);
+    this.puffTimer = Math.max(0, this.puffTimer - dt);
+  }
+
+  override anim(): number {
+    return (this.capeTimer > 0 ? Anim.Attack : 0) | (this.puffTimer > 0 ? Anim.Special : 0);
+  }
+}

+ 15 - 2
src/shared/characters/index.ts

@@ -1,17 +1,30 @@
 import { Castor } from './Castor';
 import type { Character, CharacterStats } from './Character';
+import { Chat } from './Chat';
+import { Gege } from './Gege';
 import { Generic } from './Generic';
+import { Magicien } from './Magicien';
 import { Mamie } from './Mamie';
+import { Pompier } from './Pompier';
+import { Prout } from './Prout';
 import { Pyromanchot } from './Pyromanchot';
 
-// Menu order: the three M5 characters, then the test character.
+// Menu order (readme §4 table), then the test character.
 const registry: Record<string, new (stats: CharacterStats) => Character> = {
   castor: Castor,
   mamie: Mamie,
   pyromanchot: Pyromanchot,
+  gege: Gege,
+  pompier: Pompier,
+  prout: Prout,
+  chat: Chat,
+  magicien: Magicien,
   generic: Generic,
 };
 
+/** Characters offered to players (the test character is for development only). */
+export const PLAYABLE_CHARACTER_IDS = Object.keys(registry).filter((id) => id !== 'generic');
+
 /** Ids of playable characters, in menu order. */
 export const CHARACTER_IDS = Object.keys(registry);
 
@@ -26,4 +39,4 @@ export function createCharacter(stats: CharacterStats): Character {
 }
 
 export { Anim, Character, parseCharacterStats } from './Character';
-export type { Blast, CharacterContext, CharacterStats, ProjectileSpec } from './Character';
+export type { Blast, CharacterContext, CharacterCue, CharacterStats, ProjectileSpec, Spell } from './Character';

+ 18 - 1
src/shared/constants.ts

@@ -2,7 +2,7 @@
 // (characters, maps, materials) whenever they belong to a specific entity.
 
 /** Bumped on every incompatible protocol change. */
-export const PROTOCOL_VERSION = 8;
+export const PROTOCOL_VERSION = 9;
 
 export const NET_PORT = 47100;
 export const DISCOVERY_PORT = 47101;
@@ -59,6 +59,23 @@ export const WHISK_STUN_S = 0.35;
 /** Explosions burn wool (material 7) within this multiple of their radius (readme §4, Mamie Tricot). */
 export const WOOL_BURN_RADIUS_FACTOR = 2.2;
 export const MATERIAL_WOOL = 7;
+export const MATERIAL_SPONGE = 1;
+export const MATERIAL_CREAM = 3;
+export const MATERIAL_JELLY = 4;
+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_RADIUS = 6;
+/** Only soaked sponge (and whipped cream) is that soft. */
+export const SOAK_SAG_POWER = 3;
+/** Toxic cloud: how long a player stays poisoned after leaving it, and what it does (s, ratio). */
+export const POISON_S = 1.2;
+export const POISON_SPEED_MULT = 0.7;
+export const COUGH_INTERVAL_S = 0.7;
+/** A cough stops a poisoned player's run: horizontal speed is multiplied by this. */
+export const COUGH_STUMBLE = 0.3;
 // Map modifiers (server/modifiers). Tune by playing.
 /** Strongest side wind (px/s²) and how often a new gust is drawn (s). */
 export const WIND_MAX = 520;

+ 6 - 1
src/shared/protocol.ts

@@ -1,4 +1,5 @@
 import { PROTOCOL_VERSION } from './constants';
+import type { CharacterCue } from './characters/Character';
 import type { InputState } from './input';
 import type { SequencedEvent, TerrainEvent } from './terrain/events';
 
@@ -63,6 +64,8 @@ export interface RoundState {
   finalRush: boolean;
   /** Sideways wind (px/s², positive = to the right), 0 without the wind modifier. */
   wind: number;
+  /** Toxic clouds: [x, y, radius, life left 0..1]. */
+  clouds: [number, number, number, number][];
   cherry: CherryState | null;
 }
 
@@ -73,7 +76,9 @@ export type FxEvent =
   | { kind: 'explosion'; x: number; y: number; r: number }
   | { kind: 'pickup'; id: number; item: ItemKind | 'cherry'; x: number; y: number }
   | { kind: 'cherryDrop'; id: number; x: number; y: number }
-  | { kind: 'whisk'; id: number };
+  | { kind: 'whisk'; id: number }
+  /** A character's signature cue (a fart, a water jet, a spell...), at its position. */
+  | { kind: 'cue'; id: number; cue: CharacterCue; x: number; y: number };
 
 export interface RoundResult {
   id: number;

+ 172 - 1
tests/characters.test.ts

@@ -33,7 +33,7 @@ function press(game: Game, id: number, buttons: number, x: number, y: number): F
 
 describe('roster', () => {
   it('every character has data that loads', () => {
-    expect(CHARACTER_IDS).toEqual(['castor', 'mamie', 'pyromanchot', 'generic']);
+    expect(CHARACTER_IDS).toEqual(['castor', 'mamie', 'pyromanchot', 'gege', 'pompier', 'prout', 'chat', 'magicien', 'generic']);
     for (const id of CHARACTER_IDS) expect(loadCharacterStats(ASSETS, id).id).toBe(id);
   });
 });
@@ -175,3 +175,174 @@ describe('Pyromanchot', () => {
     expect(end.anim & Anim.Stunned).toBe(0);
   });
 });
+
+describe('Gégé le Ver', () => {
+  it('swims down into the cake, then bursts out under a player and sends them flying', () => {
+    const game = newGame();
+    const gege = game.addPlayer('Gégé', 'gege');
+    const victim = game.addPlayer('Victime', 'generic');
+    game.teleport(gege.id, 600, 420); // middle tier top
+    game.teleport(victim.id, 730, 420);
+    run(game, 60);
+    // Dive.
+    for (let i = 0; i < 40; i++) {
+      const g = me(game, gege.id);
+      game.setInput(gege.id, { buttons: Button.Special, aimX: Math.round(g.x), aimY: Math.round(g.y + 200) });
+      game.step();
+    }
+    expect(me(game, gege.id).anim & Anim.Underground).toBeTruthy();
+    // Tunnel toward the victim, under them, then let go: he bursts out.
+    for (let i = 0; i < 200; i++) {
+      const g = me(game, gege.id);
+      const v = me(game, victim.id);
+      const under = Math.abs(g.x - v.x) < 20;
+      game.setInput(gege.id, { buttons: Button.Special, aimX: Math.round(v.x), aimY: Math.round(under ? v.y : 505) });
+      game.step();
+      if (under && g.y - v.y < 70) break;
+    }
+    game.setInput(gege.id, { buttons: 0, aimX: 0, aimY: 0 });
+    const fx = run(game, 2);
+    expect(fx).toContainEqual(expect.objectContaining({ kind: 'cue', id: gege.id, cue: 'emerge' }));
+    const v = me(game, victim.id);
+    expect(v.anim & Anim.Stunned).toBeTruthy();
+    expect(v.vy).toBeLessThan(-300);
+  });
+});
+
+describe('Bébé Pompier', () => {
+  it('the water jet pushes a player away and the recoil sends him backwards', () => {
+    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 + 80, b.y);
+    run(game, 1);
+    const fx = press(game, baby.id, Button.Attack, b.x + 200, b.y);
+    expect(fx).toContainEqual(expect.objectContaining({ kind: 'cue', cue: 'water' }));
+    expect(me(game, victim.id).vx).toBeGreaterThan(200);
+    expect(me(game, baby.id).vx).toBeLessThan(-200);
+  });
+
+  it('soaking turns sponge into soaked sponge, which then sags on its own', () => {
+    const game = newGame();
+    const baby = game.addPlayer('Bébé', 'pompier');
+    game.teleport(baby.id, 600, 420);
+    run(game, 60);
+    const b = me(game, baby.id);
+    press(game, baby.id, Button.Special, b.x + 60, b.y + 120);
+    const soaked = (): number => game.terrain.data.reduce((n, m) => n + (m === 8 ? 1 : 0), 0);
+    const after = soaked();
+    expect(after).toBeGreaterThan(1000);
+    run(game, 4 * TICK_RATE);
+    expect(soaked()).toBeLessThan(after); // crumbling
+  });
+});
+
+describe('Captain Prout', () => {
+  it('each thrust lifts him, is heard by all, and leaves a cloud that poisons players inside', () => {
+    const game = newGame();
+    const prout = game.addPlayer('Prout', 'prout');
+    const victim = game.addPlayer('Victime', 'generic');
+    game.teleport(prout.id, 600, 420);
+    run(game, 60);
+    const p0 = me(game, prout.id);
+    const fx = press(game, prout.id, Button.Special, p0.x, p0.y - 100);
+    expect(fx).toContainEqual(expect.objectContaining({ kind: 'cue', id: prout.id, cue: 'prout' }));
+    expect(me(game, prout.id).vy).toBeLessThan(-300);
+    expect(game.roundState().clouds.length).toBe(1);
+    const [cx, cy] = game.roundState().clouds[0];
+    game.teleport(victim.id, cx, cy);
+    run(game, 2);
+    expect(me(game, victim.id).anim & Anim.Poisoned).toBeTruthy();
+    run(game, 1);
+    game.teleport(prout.id, cx, cy);
+    run(game, 2);
+    expect(me(game, prout.id).anim & Anim.Poisoned).toBeFalsy(); // immune to his own clouds
+  });
+});
+
+describe('Le Chat', () => {
+  it('shoves a stunned player hard', () => {
+    const game = newGame();
+    const cat = game.addPlayer('Chat', 'chat');
+    const a = game.addPlayer('Assommé', 'generic');
+    game.teleport(cat.id, 600, 420);
+    run(game, 60);
+    const c = me(game, cat.id);
+    game.teleport(a.id, c.x + 50, c.y);
+    run(game, 1);
+    press(game, cat.id, Button.Attack, c.x + 50, c.y); // paw: stuns
+    expect(me(game, a.id).anim & Anim.Stunned).toBeTruthy();
+    const now = me(game, cat.id);
+    game.teleport(a.id, now.x + 55 * now.facing, now.y);
+    press(game, cat.id, Button.Special, now.x + 200 * now.facing, now.y);
+    expect(Math.abs(me(game, a.id).vx)).toBeGreaterThan(400);
+  });
+
+  it('stops to lick his paw now and then, ignoring input', () => {
+    const game = newGame();
+    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');
+    expect(licked).toBe(true);
+    expect(me(game, cat.id).anim & Anim.Busy).toBeTruthy();
+    const x0 = me(game, cat.id).x;
+    game.setInput(cat.id, { buttons: Button.Right, aimX: 0, aimY: 0 });
+    run(game, 20);
+    expect(Math.abs(me(game, cat.id).x - x0)).toBeLessThan(5);
+  });
+});
+
+describe('Magicien Raté', () => {
+  function castMany(leading: boolean, casts: number): Record<string, number> {
+    const game = newGame();
+    const mage = game.addPlayer('Mage', 'magicien');
+    const other = game.addPlayer('Autre', 'generic');
+    game.teleport(mage.id, 600, 420);
+    game.teleport(other.id, 1400, 420);
+    run(game, 60);
+    // Make him the leader (or last) by giving round points.
+    const players = (game as unknown as { players: Map<number, { score: number }> }).players;
+    players.get(mage.id)!.score = leading ? 50 : 0;
+    players.get(other.id)!.score = leading ? 0 : 50;
+    const counts: Record<string, number> = {};
+    for (let i = 0; i < casts; i++) {
+      const m = me(game, mage.id);
+      const fx = press(game, mage.id, Button.Special, m.x + 100, m.y - 50);
+      for (const e of fx) if (e.kind === 'cue' && e.cue.startsWith('spell-')) counts[e.cue] = (counts[e.cue] ?? 0) + 1;
+      run(game, Math.ceil(2.3 * TICK_RATE));
+    }
+    return counts;
+  }
+
+  it('casts every kind of spell, and fizzles far more often when leading', () => {
+    const last = castMany(false, 40);
+    expect(Object.keys(last).sort()).toEqual(['spell-jelly', 'spell-pfft', 'spell-rabbit', 'spell-swap']);
+    const lead = castMany(true, 40);
+    expect(lead['spell-pfft'] ?? 0).toBeGreaterThan((last['spell-pfft'] ?? 0) * 2);
+  });
+
+  it('the giant rabbit appears in the air and falls (an unanchored sponge chunk)', () => {
+    const game = newGame();
+    const mage = game.addPlayer('Mage', 'magicien');
+    game.teleport(mage.id, 600, 420);
+    run(game, 60);
+    for (let i = 0; i < 60; i++) {
+      const m = me(game, mage.id);
+      game.setInput(mage.id, { buttons: Button.Special, aimX: Math.round(m.x + 100), aimY: Math.round(m.y - 50) });
+      const r = game.step();
+      game.setInput(mage.id, { buttons: 0, aimX: 0, aimY: 0 });
+      if (!r.fx.some((e) => e.kind === 'cue' && e.cue === 'spell-rabbit')) {
+        run(game, Math.ceil(2.3 * TICK_RATE));
+        continue;
+      }
+      // The rabbit is filled in this very tick, and comes loose in the same detach pass.
+      expect(r.terrain.some((e) => e.ev.kind === 'detach' && !e.ev.debris)).toBe(true);
+      return;
+    }
+    throw new Error('no rabbit in 60 casts');
+  });
+});