Procházet zdrojové kódy

feat(characters): Castor Glouton, title menu and first art pass

- Castor Glouton (pulled forward from M5): bite that knocks back and stuns,
  held special that burrows toward the cursor while there is cake to eat
  (no flying), growth with every pixel eaten, slower when fat, the floor
  gives way past heavySize, digestion back to base size. He eats chocolate.
- Character API: dynamic size, hits, velocity control, anim flags.
- Title screen (DOM): nickname and character choice with rendered portraits.
- Procedural art: tileable material textures, irregular felt-pen outline,
  rim light, darker undersides, drop shadow, cavities showing the inside of
  the cake, painted characters with walk cycle, squash & stretch, chewing
  and stun stars, comic onomatopoeia, crumbs and dust, warm vignette,
  freeze-frame on hits, camera leading toward the cursor, kitchen backdrop.

No new dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Idleman před 1 týdnem
rodič
revize
d1c1e35c07

+ 36 - 0
assets/characters/castor/character.json

@@ -0,0 +1,36 @@
+{
+  "id": "castor",
+  "name": "Castor Glouton",
+  "speed": 240,
+  "acceleration": 2000,
+  "jump": 600,
+  "mass": 1.2,
+  "size": 20,
+  "cooldowns": {
+    "attack": 0.35,
+    "special": 0.05
+  },
+  "params": {
+    "biteRadius": 18,
+    "bitePower": 35,
+    "biteReach": 14,
+    "biteKnockback": 620,
+    "biteStun": 0.4,
+    "biteAnim": 0.18,
+    "eatSpeed": 150,
+    "eatInterval": 0.06,
+    "eatFrontFactor": 0.55,
+    "eatRadiusFactor": 1.45,
+    "eatPower": 60,
+    "growthPerPixel": 0.0014,
+    "maxSize": 46,
+    "minMobility": 0.45,
+    "digestRate": 0.9,
+    "heavySize": 30,
+    "crushInterval": 0.25,
+    "crushPowerPerPx": 4,
+    "eatClearFactor": 1.1,
+    "burrowMinPixels": 25,
+    "burrowGrace": 0.2
+  }
+}

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

@@ -0,0 +1,184 @@
+import type { Graphics } from 'pixi.js';
+import { OUTLINE_COLOR } from '../config';
+
+/** Everything a painter needs to draw one frame. Local space: facing right, body radius `r`, origin at the center. */
+export interface Pose {
+  /** Seconds since the view was created. */
+  time: number;
+  /** Walk cycle phase (radians). */
+  walk: number;
+  moving: boolean;
+  grounded: boolean;
+  attack: boolean;
+  special: boolean;
+  stunned: boolean;
+  /** Unit look direction in local space. */
+  lookX: number;
+  lookY: number;
+  /** 0 at base size, 1 at max size. */
+  fat: number;
+  /** Player color. */
+  color: number;
+  /** Outline width in local units. */
+  ink: number;
+}
+
+export type Painter = (g: Graphics, r: number, pose: Pose) => void;
+
+const INK = OUTLINE_COLOR;
+
+function ellipsePoly(cx: number, cy: number, rx: number, ry: number, angle: number, steps = 20): number[] {
+  const pts: number[] = [];
+  const c = Math.cos(angle);
+  const s = Math.sin(angle);
+  for (let i = 0; i < steps; i++) {
+    const a = (i / steps) * Math.PI * 2;
+    const x = Math.cos(a) * rx;
+    const y = Math.sin(a) * ry;
+    pts.push(cx + x * c - y * s, cy + x * s + y * c);
+  }
+  return pts;
+}
+
+function eyes(g: Graphics, pose: Pose, spots: [number, number][], size: number): void {
+  for (const [ex, ey] of spots) {
+    if (pose.stunned) {
+      const d = size * 0.6;
+      g.moveTo(ex - d, ey - d).lineTo(ex + d, ey + d).moveTo(ex + d, ey - d).lineTo(ex - d, ey + d).stroke({ width: pose.ink, color: INK, cap: 'round' });
+      continue;
+    }
+    g.ellipse(ex, ey, size, size * 1.2).fill(0xffffff).stroke({ width: pose.ink * 0.8, color: INK });
+    const px = ex + pose.lookX * size * 0.4;
+    const py = ey + pose.lookY * size * 0.45;
+    g.circle(px, py, size * 0.5).fill(INK);
+    g.circle(px - size * 0.18, py - size * 0.2, size * 0.17).fill(0xffffff);
+  }
+}
+
+function foot(g: Graphics, x: number, y: number, r: number, color: number, ink: number): void {
+  g.ellipse(x, y, r * 0.24, r * 0.13).fill(color).stroke({ width: ink, color: INK });
+}
+
+function stars(g: Graphics, r: number, pose: Pose): void {
+  if (!pose.stunned) return;
+  for (let i = 0; i < 3; i++) {
+    const a = pose.time * 6 + (i * Math.PI * 2) / 3;
+    const cx = Math.cos(a) * r * 0.8;
+    const cy = -r * 1.25 + Math.sin(a) * r * 0.2;
+    const pts: number[] = [];
+    for (let k = 0; k < 10; k++) {
+      const rr = k % 2 === 0 ? r * 0.2 : r * 0.08;
+      const aa = (k / 10) * Math.PI * 2 - Math.PI / 2;
+      pts.push(cx + Math.cos(aa) * rr, cy + Math.sin(aa) * rr);
+    }
+    g.poly(pts).fill(0xffe14d).stroke({ width: pose.ink * 0.6, color: INK });
+  }
+}
+
+/** Castor Glouton: brown, big teeth, paddle tail, cheeks that swell as he eats. */
+export const paintCastor: Painter = (g, r, pose) => {
+  const fur = 0xa0652d;
+  const dark = 0x6b4226;
+  const step = pose.moving && pose.grounded ? Math.sin(pose.walk) : 0;
+  const lift = pose.moving && pose.grounded ? Math.abs(Math.cos(pose.walk)) * 0.12 * r : 0;
+  const chomp = pose.special && Math.sin(pose.time * 28) > 0;
+  const mouthOpen = pose.attack || chomp;
+
+  // Tail (behind), wagging.
+  const wag = pose.moving ? Math.sin(pose.walk * 0.5) * 0.15 : Math.sin(pose.time * 2) * 0.06;
+  g.poly(ellipsePoly(-0.95 * r, 0.42 * r, 0.62 * r, 0.24 * r, -0.35 + wag)).fill(dark).stroke({ width: pose.ink, color: INK });
+  for (let i = -1; i <= 1; i++) {
+    const ox = -0.95 * r + i * 0.22 * r;
+    g.moveTo(ox - 0.08 * r, 0.3 * r + i * 0.08 * r).lineTo(ox + 0.1 * r, 0.52 * r + i * 0.08 * r).stroke({ width: pose.ink * 0.5, color: 0x4a2c17 });
+  }
+
+  foot(g, -0.38 * r - step * 0.18 * r, 0.93 * r - (step < 0 ? lift : 0), r, dark, pose.ink);
+
+  // Body + belly.
+  g.circle(0, 0, r).fill(fur).stroke({ width: pose.ink, color: INK });
+  g.ellipse(0.22 * r, 0.32 * r, 0.52 * r, 0.5 * r).fill(0xe0b07a);
+  // Fur tufts.
+  g.moveTo(-0.55 * r, -0.2 * r).lineTo(-0.42 * r, -0.12 * r).moveTo(-0.6 * r, 0.05 * r).lineTo(-0.46 * r, 0.1 * r).stroke({ width: pose.ink * 0.5, color: dark });
+
+  // Ears.
+  for (const [ex, ey, er] of [
+    [-0.32, -0.86, 0.21],
+    [0.22, -0.94, 0.18],
+  ]) {
+    g.circle(ex * r, ey * r, er * r).fill(fur).stroke({ width: pose.ink, color: INK });
+    g.circle(ex * r, ey * r, er * r * 0.5).fill(0xf2a5a5);
+  }
+
+  // Cheeks swell with food.
+  const cheek = 0.2 + pose.fat * 0.22 + (pose.special ? 0.04 : 0);
+  g.circle(0.5 * r, 0.22 * r, cheek * r).fill(0xb8763b).stroke({ width: pose.ink * 0.7, color: INK });
+  g.circle(0.44 * r, 0.16 * r, cheek * r * 0.35).fill({ color: 0xff8fa3, alpha: 0.6 });
+
+  eyes(
+    g,
+    pose,
+    [
+      [0.3 * r, -0.34 * r],
+      [0.64 * r, -0.3 * r],
+    ],
+    0.17 * r,
+  );
+
+  // Mouth and the famous teeth.
+  const jaw = mouthOpen ? 0.22 * r : 0;
+  if (mouthOpen) g.ellipse(0.8 * r, 0.24 * r, 0.2 * r, 0.12 * r + jaw * 0.5).fill(0x5a1e1e).stroke({ width: pose.ink * 0.8, color: INK });
+  for (const tx of [0.66, 0.8]) {
+    g.roundRect(tx * r, 0.05 * r, 0.13 * r, 0.26 * r, 0.03 * r).fill(0xfffbe8).stroke({ width: pose.ink * 0.7, color: INK });
+  }
+  // Nose and whiskers.
+  g.ellipse(0.96 * r, -0.06 * r, 0.14 * r, 0.1 * r).fill(INK);
+  g.circle(0.93 * r, -0.09 * r, 0.04 * r).fill(0xffffff);
+  g.moveTo(0.98 * r, 0.02 * r)
+    .lineTo(1.32 * r, -0.08 * r)
+    .moveTo(0.98 * r, 0.06 * r)
+    .lineTo(1.32 * r, 0.1 * r)
+    .stroke({ width: pose.ink * 0.4, color: INK });
+
+  foot(g, 0.36 * r + step * 0.18 * r, 0.93 * r - (step > 0 ? lift : 0), r, dark, pose.ink);
+  stars(g, r, pose);
+};
+
+/** The Tester: a squishy jelly bean with a sprout on its head. */
+export const paintGeneric: Painter = (g, r, pose) => {
+  const step = pose.moving && pose.grounded ? Math.sin(pose.walk) : 0;
+  const dark = darken(pose.color, 0.7);
+  foot(g, -0.35 * r - step * 0.18 * r, 0.93 * r, r, dark, pose.ink);
+
+  // Sprout.
+  const sway = Math.sin(pose.time * 3) * 0.1 * r;
+  g.moveTo(0, -r).quadraticCurveTo(0.05 * r, -1.25 * r, sway, -1.4 * r).stroke({ width: pose.ink, color: INK });
+  g.poly(ellipsePoly(sway + 0.18 * r, -1.42 * r, 0.2 * r, 0.1 * r, -0.4)).fill(0x7ccf5a).stroke({ width: pose.ink * 0.7, color: INK });
+
+  g.circle(0, 0, r).fill(pose.color).stroke({ width: pose.ink, color: INK });
+  g.ellipse(-0.35 * r, -0.45 * r, 0.25 * r, 0.15 * r).fill({ color: 0xffffff, alpha: 0.55 });
+  g.circle(0.55 * r, 0.25 * r, 0.14 * r).fill({ color: 0xff3b6b, alpha: 0.35 });
+
+  eyes(
+    g,
+    pose,
+    [
+      [0.12 * r, -0.2 * r],
+      [0.5 * r, -0.18 * r],
+    ],
+    0.2 * r,
+  );
+  if (pose.attack) g.ellipse(0.4 * r, 0.32 * r, 0.16 * r, 0.14 * r).fill(0x5a1e1e).stroke({ width: pose.ink * 0.7, color: INK });
+  else g.moveTo(0.18 * r, 0.3 * r).quadraticCurveTo(0.38 * r, 0.45 * r, 0.6 * r, 0.28 * r).stroke({ width: pose.ink * 0.8, color: INK, cap: 'round' });
+
+  foot(g, 0.35 * r + step * 0.18 * r, 0.93 * r, r, dark, pose.ink);
+  stars(g, r, pose);
+};
+
+function darken(color: number, k: number): number {
+  return (Math.round((color >> 16) * k) << 16) | (Math.round(((color >> 8) & 0xff) * k) << 8) | Math.round((color & 0xff) * k);
+}
+
+export const PAINTERS: Record<string, Painter> = {
+  castor: paintCastor,
+  generic: paintGeneric,
+};

+ 36 - 0
src/renderer/config.ts

@@ -0,0 +1,36 @@
+// Client-only visual tuning. Gameplay values belong in shared/constants.ts or the JSON data.
+export const CAMERA_VIEW_HEIGHT = 680;
+export const CAMERA_FOLLOW_SPEED = 8;
+/** How far the camera leads toward the mouse (fraction of the cursor offset). */
+export const CAMERA_AIM_LEAD = 0.18;
+export const OUTLINE_COLOR = 0x3b2414;
+export const OUTLINE_WIDTH = 3.5;
+
+// Terrain shading (px). The largest depth + OUTLINE_PX + 1 must stay <= RENDER_MARGIN (shared/constants).
+export const RIM_LIGHT_DEPTH = 5;
+export const RIM_LIGHT_WHITE = 0.45;
+export const UNDERSIDE_DEPTH = 4;
+export const UNDERSIDE_DARKEN = 0.78;
+export const CAVITY_SHADOW_DEPTH = 7;
+export const CAVITY_DARKEN = 0.64;
+export const DROP_SHADOW_DX = 5;
+export const DROP_SHADOW_DY = 7;
+export const DROP_SHADOW_ALPHA = 70;
+
+export const SHAKE_PER_PIXEL = 0.004;
+export const SHAKE_MAX = 12;
+export const SHAKE_DECAY = 20;
+export const HIT_SHAKE = 8;
+export const MERGE_SHAKE = 6;
+export const CRUSH_SHAKE = 12;
+export const PLAYER_SPLAT_COLOR = 0xff6f91;
+export const FREEZE_FRAME_MS = 50;
+export const PARTICLES_PER_100_PX = 6;
+export const PARTICLE_MAX = 700;
+export const PARTICLE_GRAVITY = 1200;
+export const LANDING_DUST_MIN_VY = 450;
+export const POPUP_MIN_PIXELS = 250;
+export const POPUP_COOLDOWN = 0.45;
+export const INPUT_SEND_HZ = 60;
+export const SQUASH_RECOVERY = 10;
+export const PLAYER_COLORS = [0xff6f91, 0x4dc4ff, 0x9be15d, 0xffc94d, 0xb18cff, 0xff9a4d, 0x4de0c4, 0xf15bb5];

+ 26 - 0
src/renderer/entities/ChunkView.ts

@@ -0,0 +1,26 @@
+import { Sprite, type Texture } from 'pixi.js';
+import type { ChunkState } from '../../shared/protocol';
+
+/** A falling piece of terrain. Its texture has a 1 px transparent border, hence the pivot. */
+export class ChunkView {
+  readonly sprite: Sprite;
+  /** Last pose drawn; the merge event arrives after the last snapshot that still had it. */
+  last: ChunkState;
+
+  constructor(texture: Texture, initial: ChunkState) {
+    this.sprite = new Sprite(texture);
+    this.sprite.pivot.set(1, 1);
+    this.last = initial;
+    this.update(initial);
+  }
+
+  update(state: ChunkState): void {
+    this.last = state;
+    this.sprite.position.set(state.x, state.y);
+    this.sprite.rotation = state.angle;
+  }
+
+  destroy(): void {
+    this.sprite.destroy({ texture: true, textureSource: true });
+  }
+}

+ 115 - 0
src/renderer/entities/PlayerView.ts

@@ -0,0 +1,115 @@
+import { Container, Graphics, Text } from 'pixi.js';
+import { Anim } from '../../shared/characters';
+import type { PlayerInfo, PlayerState } from '../../shared/protocol';
+import { PAINTERS, paintGeneric, type Painter, type Pose } from '../characters/painters';
+import { OUTLINE_COLOR, OUTLINE_WIDTH, PLAYER_COLORS, SQUASH_RECOVERY } from '../config';
+
+/** Max size the fat ratio is normalized against (castor maxSize is in its JSON; this only drives the cheeks). */
+const FAT_RANGE = 26;
+
+export interface PlayerEvents {
+  landed: boolean;
+  landingSpeed: number;
+  stunnedNow: boolean;
+}
+
+/** A character on screen: painter-drawn body with squash & stretch, walk cycle, and a name tag. */
+export class PlayerView {
+  readonly container = new Container();
+  private readonly body = new Graphics();
+  private readonly name: Text;
+  private readonly paint: Painter;
+  private readonly color: number;
+  private time = 0;
+  private walk = 0;
+  private squash = 0;
+  private wasGrounded = true;
+  private wasStunned = false;
+  private lastVy = 0;
+
+  constructor(readonly info: PlayerInfo) {
+    this.paint = PAINTERS[info.characterId] ?? paintGeneric;
+    this.color = PLAYER_COLORS[(info.id - 1) % PLAYER_COLORS.length];
+    this.name = new Text({
+      text: info.name,
+      style: { fontFamily: 'Comic Sans MS', fontSize: 15, fill: this.color, fontWeight: 'bold', stroke: { color: OUTLINE_COLOR, width: 4 } },
+    });
+    this.name.anchor.set(0.5, 1);
+    this.container.addChild(this.body, this.name);
+  }
+
+  /** 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 });
+  }
+
+  update(state: PlayerState, aimX: number, aimY: number, dt: number): PlayerEvents {
+    this.time += dt;
+    const size = state.size;
+    this.container.position.set(state.x, state.y);
+
+    // Landing squashes, jumping stretches.
+    const events: PlayerEvents = { landed: false, landingSpeed: 0, 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;
+    this.wasGrounded = state.grounded;
+    this.lastVy = state.vy;
+    const stunned = (state.anim & Anim.Stunned) !== 0;
+    events.stunnedNow = stunned && !this.wasStunned;
+    this.wasStunned = stunned;
+    this.squash += (0 - this.squash) * Math.min(1, SQUASH_RECOVERY * dt);
+
+    const moving = Math.abs(state.vx) > 20;
+    this.walk += dt * (moving ? Math.abs(state.vx) / 18 : 0);
+
+    // Look direction in local (facing-right) space.
+    const dx = (aimX - state.x) * state.facing;
+    const dy = aimY - state.y;
+    const len = Math.hypot(dx, dy) || 1;
+    this.draw(size, state.facing, dx / len, dy / len, {
+      moving,
+      grounded: state.grounded,
+      attack: (state.anim & Anim.Attack) !== 0,
+      special: (state.anim & Anim.Special) !== 0,
+      stunned,
+    });
+    return events;
+  }
+
+  private draw(
+    size: number,
+    facing: number,
+    lookX: number,
+    lookY: number,
+    flags: Pick<Pose, 'moving' | 'grounded' | 'attack' | 'special' | 'stunned'>,
+  ): void {
+    const base = this.info.radius;
+    const scale = size / base;
+    const sx = 1 + this.squash;
+    const sy = 1 - this.squash;
+    // Wobble while eating.
+    const wobble = flags.special ? 1 + Math.sin(this.time * 28) * 0.04 : 1;
+    this.body.scale.set(facing * scale * sx * wobble, scale * sy / wobble);
+    // Keep the feet on the ground while squashed.
+    this.body.y = size * (1 - sy);
+    this.body.clear();
+    this.paint(this.body, base, {
+      time: this.time,
+      walk: this.walk,
+      ...flags,
+      lookX,
+      lookY,
+      fat: Math.min(1, Math.max(0, (size - base) / FAT_RANGE)),
+      color: this.color,
+      ink: OUTLINE_WIDTH / scale,
+    });
+    this.name.y = -size * 1.15 - 10;
+  }
+
+  destroy(): void {
+    this.container.destroy({ children: true });
+  }
+}

+ 99 - 0
src/renderer/fx/Particles.ts

@@ -0,0 +1,99 @@
+import { Graphics } from 'pixi.js';
+import { OUTLINE_COLOR, PARTICLE_GRAVITY, PARTICLE_MAX } from '../config';
+
+interface Particle {
+  x: number;
+  y: number;
+  vx: number;
+  vy: number;
+  life: number;
+  maxLife: number;
+  size: number;
+  color: number;
+  gravity: number;
+  /** Crumbs are chunky polygons, puffs are soft circles. */
+  kind: 'crumb' | 'puff';
+  angle: number;
+  spin: number;
+}
+
+/** Cosmetic crumbs and dust. Uses Math.random: purely visual, never part of the simulation. */
+export class Particles {
+  readonly graphics = new Graphics();
+  private particles: Particle[] = [];
+
+  private add(p: Particle): void {
+    if (this.particles.length >= PARTICLE_MAX) this.particles.shift();
+    this.particles.push(p);
+  }
+
+  burst(x: number, y: number, count: number, color: number, speed = 380): void {
+    for (let i = 0; i < count; i++) {
+      const a = Math.random() * Math.PI * 2;
+      const s = speed * (0.3 + Math.random() * 0.7);
+      const life = 0.5 + Math.random() * 0.7;
+      this.add({
+        x: x + (Math.random() - 0.5) * 20,
+        y: y + (Math.random() - 0.5) * 20,
+        vx: Math.cos(a) * s,
+        vy: Math.sin(a) * s - 220,
+        life,
+        maxLife: life,
+        size: 2.5 + Math.random() * 4.5,
+        color,
+        gravity: PARTICLE_GRAVITY,
+        kind: 'crumb',
+        angle: Math.random() * Math.PI,
+        spin: (Math.random() - 0.5) * 20,
+      });
+    }
+  }
+
+  /** Soft dust cloud, e.g. on landing. */
+  puff(x: number, y: number, count: number, color = 0xfff6e4): void {
+    for (let i = 0; i < count; i++) {
+      const dir = i % 2 === 0 ? 1 : -1;
+      const life = 0.35 + Math.random() * 0.3;
+      this.add({
+        x,
+        y,
+        vx: dir * (80 + Math.random() * 160),
+        vy: -20 - Math.random() * 60,
+        life,
+        maxLife: life,
+        size: 5 + Math.random() * 6,
+        color,
+        gravity: 0,
+        kind: 'puff',
+        angle: 0,
+        spin: 0,
+      });
+    }
+  }
+
+  update(dt: number): void {
+    const g = this.graphics;
+    g.clear();
+    this.particles = this.particles.filter((p) => (p.life -= dt) > 0);
+    for (const p of this.particles) {
+      p.vy += p.gravity * dt;
+      p.x += p.vx * dt;
+      p.y += p.vy * dt;
+      p.angle += p.spin * dt;
+      const k = p.life / p.maxLife;
+      if (p.kind === 'puff') {
+        p.vx *= 1 - 3 * dt;
+        g.circle(p.x, p.y, p.size * (1.6 - k * 0.6)).fill({ color: p.color, alpha: k * 0.8 }).stroke({ width: 2, color: OUTLINE_COLOR, alpha: k * 0.5 });
+        continue;
+      }
+      const s = p.size * (0.5 + 0.5 * k);
+      const pts: number[] = [];
+      for (let i = 0; i < 4; i++) {
+        const a = p.angle + (i * Math.PI) / 2 + (i % 2) * 0.4;
+        const rr = s * (i % 2 ? 0.75 : 1);
+        pts.push(p.x + Math.cos(a) * rr, p.y + Math.sin(a) * rr);
+      }
+      g.poly(pts).fill(p.color).stroke({ width: 1.5, color: OUTLINE_COLOR, alpha: Math.min(1, k * 1.5) });
+    }
+  }
+}

+ 54 - 0
src/renderer/fx/Popups.ts

@@ -0,0 +1,54 @@
+import { Container, Text } from 'pixi.js';
+import { OUTLINE_COLOR } from '../config';
+
+interface Popup {
+  text: Text;
+  life: number;
+  maxLife: number;
+  vy: number;
+}
+
+const COLORS = [0xffe14d, 0xff6f91, 0x4dc4ff, 0x9be15d];
+
+/** Comic-book onomatopoeia that pop, wobble and fade. Purely visual. */
+export class Popups {
+  readonly container = new Container();
+  private popups: Popup[] = [];
+
+  spawn(x: number, y: number, words: string[], size = 34): void {
+    const word = words[Math.floor(Math.random() * words.length)];
+    const text = new Text({
+      text: word,
+      style: {
+        fontFamily: 'Comic Sans MS',
+        fontSize: size,
+        fontWeight: 'bold',
+        fill: COLORS[Math.floor(Math.random() * COLORS.length)],
+        stroke: { color: OUTLINE_COLOR, width: 6, join: 'round' },
+        dropShadow: { color: OUTLINE_COLOR, distance: 3, angle: Math.PI / 4, blur: 0, alpha: 1 },
+      },
+    });
+    text.anchor.set(0.5);
+    text.position.set(x + (Math.random() - 0.5) * 30, y - 20);
+    text.rotation = (Math.random() - 0.5) * 0.5;
+    this.container.addChild(text);
+    this.popups.push({ text, life: 0.8, maxLife: 0.8, vy: -90 });
+  }
+
+  update(dt: number): void {
+    this.popups = this.popups.filter((p) => {
+      p.life -= dt;
+      if (p.life <= 0) {
+        p.text.destroy();
+        return false;
+      }
+      const age = p.maxLife - p.life;
+      // Overshooting pop-in, then drift up and fade.
+      const s = age < 0.12 ? 0.3 + (age / 0.12) * 1.0 : 1.3 - Math.min(0.3, (age - 0.12) * 2);
+      p.text.scale.set(s);
+      p.text.y += p.vy * dt;
+      p.text.alpha = Math.min(1, p.life / 0.25);
+      return true;
+    });
+  }
+}

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

@@ -0,0 +1,37 @@
+// Every user-facing string lives here.
+export const fr = {
+  title: 'GOÛTERMAGEDDON',
+  subtitle: 'La baston la plus sucrée du LAN',
+  nameLabel: 'Ton pseudo',
+  chooseCharacter: 'Choisis ton gourmand',
+  play: 'À table !',
+  connecting: 'Mise au four du serveur…',
+  loadingMap: 'Préparation du goûter…',
+  disconnected: 'Connexion perdue. Le goûter est fini ?',
+  rejectedVersion: (server: number, client: number) =>
+    `Version incompatible : le serveur est en v${server}, toi en v${client}. Mettez-vous à jour !`,
+  rejectedFull: 'La table est pleine (8 joueurs max).',
+  rejectedCharacter: 'Ce personnage n’existe pas sur ce serveur.',
+  loadError: (msg: string) => `Impossible de charger la partie : ${msg}`,
+  help: {
+    generic: 'Q/D : courir · Espace/Z : sauter · Clic gauche : croquer le décor',
+    castor: 'Q/D : courir · Espace/Z : sauter · Clic gauche : coup de dents · Clic droit (maintenu) : manger le décor',
+  } as Record<string, string>,
+  fps: (n: number) => `${n} FPS`,
+  defaultName: 'Gourmand',
+  characters: {
+    generic: { name: 'Le Testeur', desc: 'Court, saute, croque. Rien de plus, rien de moins.' },
+    castor: {
+      name: 'Castor Glouton',
+      desc: 'Mange le décor en avançant. Mais plus il mange, plus il grossit… jusqu’à passer à travers le sol.',
+    },
+  } as Record<string, { name: string; desc: string }>,
+  /** Comic-book sound effects shown on impacts. */
+  onomatopoeia: {
+    crunch: ['CROC !', 'CRUNCH !', 'SCRONCH !', 'CRAC !'],
+    munch: ['MIAM !', 'NOM NOM', 'GLOUPS', 'SLURP !'],
+    hit: ['BONK !', 'PAF !', 'TCHAC !', 'BIM !'],
+    thud: ['BOUM !', 'POUF !', 'BLAM !'],
+    crush: ['SPLATCH !', 'CRÊPE !', 'SCHPLONK !'],
+  },
+};

+ 18 - 0
src/renderer/index.html

@@ -0,0 +1,18 @@
+<!doctype html>
+<html lang="fr">
+  <head>
+    <meta charset="UTF-8" />
+    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; connect-src 'self' ws: http://localhost:*" />
+    <title>Goûtermageddon</title>
+    <link rel="stylesheet" href="./style.css" />
+  </head>
+  <body>
+    <div id="game"></div>
+    <div id="hud">
+      <div id="status"></div>
+      <div id="help"></div>
+      <div id="fps"></div>
+    </div>
+    <script type="module" src="./main.ts"></script>
+  </body>
+</html>

+ 50 - 0
src/renderer/input.ts

@@ -0,0 +1,50 @@
+import { Button } from '../shared/input';
+
+// KeyboardEvent.code is layout-independent: KeyA/KeyW are Q/Z on AZERTY.
+const KEYMAP: Record<string, number> = {
+  KeyA: Button.Left,
+  ArrowLeft: Button.Left,
+  KeyD: Button.Right,
+  ArrowRight: Button.Right,
+  KeyW: Button.Jump,
+  ArrowUp: Button.Jump,
+  Space: Button.Jump,
+  KeyS: Button.Down,
+  ArrowDown: Button.Down,
+  KeyE: Button.Special,
+};
+
+/** Tracks keyboard/mouse state as a button bitmask plus the mouse position in screen pixels. */
+export class InputTracker {
+  buttons = 0;
+  mouseX = 0;
+  mouseY = 0;
+
+  constructor(target: HTMLElement) {
+    window.addEventListener('keydown', (e) => {
+      const b = KEYMAP[e.code];
+      if (b) {
+        this.buttons |= b;
+        e.preventDefault();
+      }
+    });
+    window.addEventListener('keyup', (e) => {
+      const b = KEYMAP[e.code];
+      if (b) this.buttons &= ~b;
+    });
+    window.addEventListener('blur', () => (this.buttons = 0));
+    target.addEventListener('pointermove', (e) => {
+      this.mouseX = e.clientX;
+      this.mouseY = e.clientY;
+    });
+    target.addEventListener('pointerdown', (e) => {
+      if (e.button === 0) this.buttons |= Button.Attack;
+      if (e.button === 2) this.buttons |= Button.Special;
+    });
+    window.addEventListener('pointerup', (e) => {
+      if (e.button === 0) this.buttons &= ~Button.Attack;
+      if (e.button === 2) this.buttons &= ~Button.Special;
+    });
+    target.addEventListener('contextmenu', (e) => e.preventDefault());
+  }
+}

+ 356 - 0
src/renderer/main.ts

@@ -0,0 +1,356 @@
+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 } from '../shared/characters';
+import { DEFAULT_CHARACTER_ID, INTERPOLATION_DELAY_MS, PROTOCOL_VERSION } from '../shared/constants';
+import { buildTerrain, parseMapDefinition } from '../shared/maps/loader';
+import { parseHexColor, parseMaterials, type MaterialRegistry } from '../shared/materials';
+import { hello, type ChunkState, type PlayerInfo, type PlayerState, type ServerMessage } from '../shared/protocol';
+import type { TerrainBitmap } from '../shared/terrain/bitmap';
+import { applyTerrainEvent, type SequencedEvent, type TerrainEvent } from '../shared/terrain/events';
+import { rleDecode } from '../shared/terrain/rle';
+import {
+  CAMERA_AIM_LEAD,
+  CAMERA_FOLLOW_SPEED,
+  CAMERA_VIEW_HEIGHT,
+  CRUSH_SHAKE,
+  FREEZE_FRAME_MS,
+  HIT_SHAKE,
+  INPUT_SEND_HZ,
+  LANDING_DUST_MIN_VY,
+  MERGE_SHAKE,
+  PARTICLES_PER_100_PX,
+  PLAYER_SPLAT_COLOR,
+  POPUP_COOLDOWN,
+  POPUP_MIN_PIXELS,
+  SHAKE_DECAY,
+  SHAKE_MAX,
+  SHAKE_PER_PIXEL,
+} from './config';
+import { ChunkView } from './entities/ChunkView';
+import { PlayerView } from './entities/PlayerView';
+import { Particles } from './fx/Particles';
+import { Popups } from './fx/Popups';
+import { fr } from './i18n/fr';
+import { InputTracker } from './input';
+import { Connection } from './net/connection';
+import { SnapshotBuffer } from './net/interpolation';
+import { TerrainView } from './terrain/TerrainView';
+import { showMenu } from './ui/menu';
+
+const PORTRAIT_RADIUS = 34;
+const EATING_POPUP_RANGE = 120;
+
+const statusEl = document.getElementById('status')!;
+const fpsEl = document.getElementById('fps')!;
+const helpEl = document.getElementById('help')!;
+const setStatus = (text: string): void => {
+  statusEl.textContent = text;
+};
+
+interface World {
+  materials: MaterialRegistry;
+  terrain: TerrainBitmap;
+  view: TerrainView;
+  lastSeq: number;
+}
+
+async function loadWorld(mapId: string, materials: MaterialRegistry, root: Container): Promise<World> {
+  const files = await window.assets.map(mapId);
+  const def = parseMapDefinition(files.definition, mapId);
+  const terrain = buildTerrain(def, files.terrain, materials);
+
+  const bitmap = await createImageBitmap(new Blob([files.background as BlobPart], { type: 'image/png' }));
+  root.addChild(new Sprite(new Texture({ source: new ImageSource({ resource: bitmap }) })));
+
+  const view = new TerrainView(terrain, materials);
+  root.addChild(view.container);
+  return { materials, terrain, view, lastSeq: 0 };
+}
+
+/** Screen-space warm vignette drawn over the game. */
+function makeVignette(): Sprite {
+  const canvas = document.createElement('canvas');
+  canvas.width = 512;
+  canvas.height = 512;
+  const ctx = canvas.getContext('2d')!;
+  const grad = ctx.createRadialGradient(256, 230, 140, 256, 256, 380);
+  grad.addColorStop(0, 'rgba(255, 220, 160, 0)');
+  grad.addColorStop(0.7, 'rgba(120, 60, 20, 0.12)');
+  grad.addColorStop(1, 'rgba(59, 36, 20, 0.45)');
+  ctx.fillStyle = grad;
+  ctx.fillRect(0, 0, 512, 512);
+  return new Sprite(new Texture({ source: new CanvasSource({ resource: canvas }) }));
+}
+
+/** 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) => {
+    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);
+    view.destroy();
+  });
+  return out;
+}
+
+async function start(): Promise<void> {
+  const app = new Application();
+  const host = document.getElementById('game')!;
+  await app.init({ resizeTo: window, background: 0xfff1d6, antialias: true });
+  host.appendChild(app.canvas);
+
+  const choice = await showMenu(CHARACTER_IDS, DEFAULT_CHARACTER_ID, makePortraits(app));
+  helpEl.textContent = fr.help[choice.characterId] ?? '';
+
+  const camera = new Container();
+  const worldRoot = new Container();
+  camera.addChild(worldRoot);
+  const vignette = makeVignette();
+  app.stage.addChild(camera, vignette);
+  const particles = new Particles();
+  const popups = new Popups();
+  const playersLayer = new Container();
+  const chunksLayer = new Container();
+
+  setStatus(fr.connecting);
+  const { port } = await window.lan.host();
+  const materials = parseMaterials(await window.assets.materials());
+
+  let world: World | null = null;
+  let localId = -1;
+  let pendingEvents: SequencedEvent[] = [];
+  const infos = new Map<number, PlayerInfo>();
+  const views = new Map<number, PlayerView>();
+  const chunkViews = new Map<number, ChunkView>();
+  const snapshots = new SnapshotBuffer();
+  const input = new InputTracker(app.canvas);
+  let states = new Map<number, PlayerState>();
+  let shake = 0;
+  let freezeUntil = 0;
+  let popupCooldown = 0;
+
+  const nearEater = (x: number, y: number): boolean => {
+    for (const s of states.values()) {
+      if (s.anim & Anim.Special && Math.hypot(s.x - x, s.y - y) < EATING_POPUP_RANGE) return true;
+    }
+    return false;
+  };
+
+  const addChunk = (id: number, mask: Uint8Array, w: number, h: number, state: ChunkState): void => {
+    if (!world || chunkViews.has(id)) return;
+    const view = new ChunkView(world.view.makeChunkTexture(mask, w, h, Math.round(state.x), Math.round(state.y)), state);
+    chunkViews.set(id, view);
+    chunksLayer.addChild(view.sprite);
+  };
+
+  const removeChunk = (id: number): void => {
+    chunkViews.get(id)?.destroy();
+    chunkViews.delete(id);
+  };
+
+  /** Most common material of a mask, for debris colors. */
+  const mainColor = (cells: Uint8Array): number => {
+    const counts = new Map<number, number>();
+    for (const v of cells) if (v !== 0) counts.set(v, (counts.get(v) ?? 0) + 1);
+    let best = 1;
+    let bestN = -1;
+    for (const [v, n] of counts) if (n > bestN) [best, bestN] = [v, n];
+    const m = world?.materials.byId[best];
+    return m ? parseHexColor(m.color) : 0xffffff;
+  };
+
+  /**
+   * Applies one terrain event in sequence. `live` is false while replaying history:
+   * no effects, and no chunk sprites (chunks still falling come from the welcome message).
+   */
+  const applyServerEvent = (seq: number, ev: TerrainEvent, live: boolean): void => {
+    if (!world) {
+      pendingEvents.push({ seq, ev });
+      return;
+    }
+    if (seq !== world.lastSeq + 1) console.error(`[net] terrain event out of order: got ${seq}, expected ${world.lastSeq + 1}`);
+    world.lastSeq = seq;
+    const { removedByMaterial } = applyTerrainEvent(world.terrain, ev, world.materials);
+    world.view.onEvent(ev);
+
+    if (ev.kind === 'op') {
+      if (!live) return;
+      const { op } = ev;
+      let total = 0;
+      for (const [mat, count] of removedByMaterial) {
+        total += count;
+        const m = world.materials.byId[mat];
+        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);
+      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);
+      }
+      return;
+    }
+
+    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 });
+      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);
+    shake = Math.min(SHAKE_MAX, shake + MERGE_SHAKE);
+    popups.spawn(ev.x + ev.w / 2, ev.y, fr.onomatopoeia.thud, 30);
+  };
+
+  const onMessage = (msg: ServerMessage): void => {
+    switch (msg.t) {
+      case 'welcome':
+        localId = msg.playerId;
+        msg.players.forEach((p) => infos.set(p.id, p));
+        setStatus(fr.loadingMap);
+        void loadWorld(msg.mapId, materials, worldRoot)
+          .then((w) => {
+            world = w;
+            worldRoot.addChild(chunksLayer, playersLayer, particles.graphics, popups.container);
+            for (const { seq, ev } of msg.events) applyServerEvent(seq, ev, false);
+            for (const c of msg.chunks) addChunk(c.id, rleDecode(c.mask, c.w * c.h), c.w, c.h, c.state);
+            const live = pendingEvents;
+            pendingEvents = [];
+            for (const { seq, ev } of live) applyServerEvent(seq, ev, true);
+            setStatus('');
+          })
+          .catch((err: unknown) => setStatus(fr.loadError(err instanceof Error ? err.message : String(err))));
+        break;
+      case 'reject':
+        setStatus(
+          msg.reason === 'version'
+            ? fr.rejectedVersion(msg.serverVersion, PROTOCOL_VERSION)
+            : msg.reason === 'character'
+              ? fr.rejectedCharacter
+              : fr.rejectedFull,
+        );
+        break;
+      case 'playerJoined':
+        infos.set(msg.player.id, msg.player);
+        break;
+      case 'playerLeft':
+        infos.delete(msg.id);
+        views.get(msg.id)?.destroy();
+        views.delete(msg.id);
+        break;
+      case 'terrain':
+        applyServerEvent(msg.seq, msg.ev, true);
+        break;
+      case 'chunkRemoved':
+        removeChunk(msg.id);
+        break;
+      case 'crushed':
+        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);
+        freezeUntil = performance.now() + FREEZE_FRAME_MS * 2;
+        break;
+      case 'snapshot':
+        snapshots.push(performance.now(), msg.players, msg.chunks);
+        break;
+    }
+  };
+
+  const conn = new Connection(
+    `ws://127.0.0.1:${port}`,
+    onMessage,
+    () => conn.send(hello(choice.name, choice.characterId)),
+    () => setStatus(fr.disconnected),
+  );
+
+  const screenToWorld = (sx: number, sy: number): [number, number] => [
+    (sx - camera.x) / camera.scale.x - worldRoot.x,
+    (sy - camera.y) / camera.scale.y - worldRoot.y,
+  ];
+
+  setInterval(() => {
+    const [aimX, aimY] = screenToWorld(input.mouseX, input.mouseY);
+    conn.send({ t: 'input', buttons: input.buttons, aimX: Math.round(aimX), aimY: Math.round(aimY) });
+  }, 1000 / INPUT_SEND_HZ);
+
+  let camX = 0;
+  let camY = 0;
+  let cameraReady = false;
+  let fpsAcc = 0;
+  let fpsFrames = 0;
+  app.ticker.add((ticker) => {
+    const dt = Math.min(0.05, ticker.deltaMS / 1000);
+    fpsAcc += ticker.deltaMS / 1000;
+    fpsFrames++;
+    if (fpsAcc >= 0.5) {
+      fpsEl.textContent = fr.fps(Math.round(fpsFrames / fpsAcc));
+      fpsAcc = 0;
+      fpsFrames = 0;
+    }
+    vignette.width = app.screen.width;
+    vignette.height = app.screen.height;
+    if (!world) return;
+    // Freeze-frame: hold the last image for a beat on big hits.
+    if (performance.now() < freezeUntil) return;
+
+    world.view.update();
+    particles.update(dt);
+    popups.update(dt);
+    popupCooldown -= dt;
+
+    const sample = snapshots.sample(performance.now() - INTERPOLATION_DELAY_MS);
+    states = sample.players;
+    for (const [id, view] of chunkViews) {
+      const s = sample.chunks.get(id);
+      if (s) view.update(s);
+    }
+    const [aimX, aimY] = screenToWorld(input.mouseX, input.mouseY);
+    for (const [id, state] of states) {
+      const info = infos.get(id);
+      if (!info) continue;
+      let view = views.get(id);
+      if (!view) {
+        view = new PlayerView(info);
+        views.set(id, view);
+        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.stunnedNow) {
+        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);
+        freezeUntil = performance.now() + FREEZE_FRAME_MS;
+      }
+    }
+
+    // Camera: follow the local player with a lead toward the cursor, clamped to the map, with screen shake.
+    const scale = app.screen.height / CAMERA_VIEW_HEIGHT;
+    camera.scale.set(scale);
+    const me = states.get(localId);
+    const viewW = app.screen.width / scale;
+    const viewH = app.screen.height / scale;
+    if (me) {
+      const tx = me.x + (aimX - me.x) * CAMERA_AIM_LEAD - viewW / 2;
+      const ty = me.y + (aimY - me.y) * CAMERA_AIM_LEAD - viewH / 2;
+      const k = cameraReady ? Math.min(1, CAMERA_FOLLOW_SPEED * dt) : 1;
+      cameraReady = true;
+      camX += (tx - camX) * k;
+      camY += (ty - camY) * k;
+    }
+    const { width, height } = world.terrain;
+    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);
+    worldRoot.position.set(Math.round(-cx + (Math.random() - 0.5) * shake * 2), Math.round(-cy + (Math.random() - 0.5) * shake * 2));
+  });
+}
+
+start().catch((err: unknown) => {
+  console.error(err);
+  setStatus(fr.loadError(err instanceof Error ? err.message : String(err)));
+});

+ 26 - 0
src/renderer/net/connection.ts

@@ -0,0 +1,26 @@
+import { Packr } from 'msgpackr';
+import type { ClientMessage, ServerMessage } from '../../shared/protocol';
+
+const packr = new Packr({ useRecords: false });
+
+/** Thin msgpack-over-WebSocket client. */
+export class Connection {
+  private readonly socket: WebSocket;
+
+  constructor(
+    url: string,
+    onMessage: (msg: ServerMessage) => void,
+    onOpen: () => void,
+    onClose: () => void,
+  ) {
+    this.socket = new WebSocket(url);
+    this.socket.binaryType = 'arraybuffer';
+    this.socket.onopen = onOpen;
+    this.socket.onclose = onClose;
+    this.socket.onmessage = (ev: MessageEvent<ArrayBuffer>) => onMessage(packr.unpack(new Uint8Array(ev.data)) as ServerMessage);
+  }
+
+  send(msg: ClientMessage): void {
+    if (this.socket.readyState === WebSocket.OPEN) this.socket.send(packr.pack(msg));
+  }
+}

+ 57 - 0
src/renderer/net/interpolation.ts

@@ -0,0 +1,57 @@
+import type { ChunkState, PlayerState } from '../../shared/protocol';
+
+interface Timed {
+  time: number;
+  players: Map<number, PlayerState>;
+  chunks: Map<number, ChunkState>;
+}
+
+export interface Sample {
+  players: Map<number, PlayerState>;
+  chunks: Map<number, ChunkState>;
+}
+
+const MAX_BUFFER = 32;
+
+/** Shortest-path angle interpolation. */
+function lerpAngle(a: number, b: number, t: number): number {
+  let d = b - a;
+  while (d > Math.PI) d -= Math.PI * 2;
+  while (d < -Math.PI) d += Math.PI * 2;
+  return a + d * t;
+}
+
+/** Buffers snapshots and renders entities slightly in the past, lerping between the two around the render time. */
+export class SnapshotBuffer {
+  private buffer: Timed[] = [];
+
+  push(time: number, players: PlayerState[], chunks: ChunkState[]): void {
+    this.buffer.push({ time, players: new Map(players.map((p) => [p.id, p])), chunks: new Map(chunks.map((c) => [c.id, c])) });
+    if (this.buffer.length > MAX_BUFFER) this.buffer.shift();
+  }
+
+  sample(renderTime: number): Sample {
+    const b = this.buffer;
+    if (b.length === 0) return { players: new Map(), chunks: new Map() };
+    if (renderTime <= b[0].time) return b[0];
+    for (let i = b.length - 1; i > 0; i--) {
+      const a = b[i - 1];
+      const c = b[i];
+      if (renderTime < a.time) continue;
+      if (renderTime > c.time) return c;
+      const t = (renderTime - a.time) / (c.time - a.time || 1);
+      const players = new Map<number, PlayerState>();
+      for (const [id, pc] of c.players) {
+        const pa = a.players.get(id);
+        players.set(id, pa ? { ...pc, x: pa.x + (pc.x - pa.x) * t, y: pa.y + (pc.y - pa.y) * t } : pc);
+      }
+      const chunks = new Map<number, ChunkState>();
+      for (const [id, cc] of c.chunks) {
+        const ca = a.chunks.get(id);
+        chunks.set(id, ca ? { id, x: ca.x + (cc.x - ca.x) * t, y: ca.y + (cc.y - ca.y) * t, angle: lerpAngle(ca.angle, cc.angle, t) } : cc);
+      }
+      return { players, chunks };
+    }
+    return b[b.length - 1];
+  }
+}

+ 214 - 0
src/renderer/style.css

@@ -0,0 +1,214 @@
+:root {
+  --ink: #3b2414;
+  --paper: #fff6e4;
+  --cream: #fff1d6;
+  --pink: #ff6f91;
+  --pink-dark: #d94a6f;
+  --sky: #bfe6ff;
+}
+
+html,
+body {
+  margin: 0;
+  height: 100%;
+  overflow: hidden;
+  background: var(--cream);
+  font-family: 'Comic Sans MS', 'Comic Neue', 'Chalkboard SE', 'Segoe UI', sans-serif;
+  color: var(--ink);
+  user-select: none;
+}
+
+#game {
+  position: fixed;
+  inset: 0;
+}
+
+#game canvas {
+  display: block;
+  cursor: crosshair;
+}
+
+#hud {
+  position: fixed;
+  inset: 0;
+  pointer-events: none;
+}
+
+#status {
+  position: absolute;
+  top: 40%;
+  width: 100%;
+  text-align: center;
+  font-size: 34px;
+  font-weight: bold;
+  text-shadow:
+    3px 3px 0 #fff,
+    -2px -2px 0 #fff;
+}
+
+#help {
+  position: absolute;
+  left: 16px;
+  bottom: 12px;
+  font-size: 14px;
+  background: var(--paper);
+  border: 3px solid var(--ink);
+  border-radius: 14px 10px 16px 9px;
+  padding: 6px 12px;
+  box-shadow: 4px 4px 0 rgba(59, 36, 20, 0.35);
+}
+
+#help:empty {
+  display: none;
+}
+
+#fps {
+  position: absolute;
+  right: 16px;
+  top: 12px;
+  font-size: 13px;
+  opacity: 0.6;
+}
+
+/* ---------- Title menu ---------- */
+
+#menu {
+  position: fixed;
+  inset: 0;
+  display: grid;
+  place-items: center;
+  background:
+    radial-gradient(circle at 50% 40%, rgba(255, 246, 228, 0.55), rgba(59, 36, 20, 0.45)),
+    repeating-linear-gradient(45deg, rgba(255, 111, 145, 0.18) 0 24px, transparent 24px 48px);
+  z-index: 10;
+}
+
+.menu-card {
+  width: min(760px, calc(100vw - 32px));
+  background: var(--paper);
+  border: 4px solid var(--ink);
+  border-radius: 28px 22px 30px 20px;
+  box-shadow: 8px 10px 0 rgba(59, 36, 20, 0.45);
+  padding: 22px 28px 26px;
+  text-align: center;
+  transform: rotate(-0.6deg);
+}
+
+.menu-card h1 {
+  margin: 0;
+  font-size: clamp(34px, 6vw, 58px);
+  letter-spacing: 2px;
+  color: var(--pink);
+  -webkit-text-stroke: 2px var(--ink);
+  text-shadow: 4px 4px 0 var(--ink);
+  transform: rotate(-1.5deg);
+}
+
+.subtitle {
+  margin: 6px 0 18px;
+  font-size: 18px;
+}
+
+.name-label {
+  display: inline-flex;
+  gap: 10px;
+  align-items: center;
+  font-weight: bold;
+  font-size: 18px;
+}
+
+.name-label input {
+  font: inherit;
+  font-weight: normal;
+  border: 3px solid var(--ink);
+  border-radius: 12px 8px 12px 9px;
+  padding: 6px 12px;
+  background: #fff;
+  color: var(--ink);
+  width: 200px;
+}
+
+.menu-card h2 {
+  margin: 20px 0 10px;
+  font-size: 20px;
+}
+
+.characters {
+  display: flex;
+  gap: 16px;
+  justify-content: center;
+  flex-wrap: wrap;
+}
+
+.character {
+  width: 220px;
+  font: inherit;
+  color: inherit;
+  background: #fff;
+  border: 3px solid var(--ink);
+  border-radius: 18px 14px 20px 12px;
+  padding: 10px 12px 14px;
+  cursor: pointer;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 4px;
+  transition:
+    transform 0.12s,
+    box-shadow 0.12s,
+    background 0.12s;
+  box-shadow: 4px 4px 0 rgba(59, 36, 20, 0.3);
+}
+
+.character:hover {
+  transform: translateY(-3px) rotate(-1deg);
+}
+
+.character.selected {
+  background: #ffe3ea;
+  box-shadow:
+    0 0 0 4px var(--pink),
+    6px 6px 0 rgba(59, 36, 20, 0.4);
+  transform: translateY(-4px) rotate(1deg);
+}
+
+.character strong {
+  font-size: 19px;
+}
+
+.character span {
+  font-size: 13px;
+  line-height: 1.3;
+}
+
+.portrait {
+  height: 120px;
+  width: auto;
+  max-width: 180px;
+  object-fit: contain;
+}
+
+#menu-play {
+  margin-top: 22px;
+  font: inherit;
+  font-size: 26px;
+  font-weight: bold;
+  color: #fff;
+  background: var(--pink);
+  border: 4px solid var(--ink);
+  border-radius: 20px 14px 22px 12px;
+  padding: 8px 34px;
+  cursor: pointer;
+  box-shadow: 5px 5px 0 var(--ink);
+  text-shadow: 2px 2px 0 var(--ink);
+}
+
+#menu-play:hover {
+  background: var(--pink-dark);
+  transform: rotate(-2deg) scale(1.05);
+}
+
+#menu-play:active {
+  transform: translate(3px, 3px);
+  box-shadow: 2px 2px 0 var(--ink);
+}

+ 300 - 0
src/renderer/terrain/TerrainView.ts

@@ -0,0 +1,300 @@
+import { CanvasSource, Container, Sprite, Texture } from 'pixi.js';
+import { CHUNK_SIZE, MATERIAL_EMPTY, OUTLINE_PX } from '../../shared/constants';
+import { parseHexColor, type MaterialRegistry } from '../../shared/materials';
+import type { TerrainBitmap } from '../../shared/terrain/bitmap';
+import type { TerrainEvent } from '../../shared/terrain/events';
+import { rleDecode } from '../../shared/terrain/rle';
+import {
+  CAVITY_DARKEN,
+  CAVITY_SHADOW_DEPTH,
+  DROP_SHADOW_ALPHA,
+  DROP_SHADOW_DX,
+  DROP_SHADOW_DY,
+  OUTLINE_COLOR,
+  RIM_LIGHT_DEPTH,
+  RIM_LIGHT_WHITE,
+  UNDERSIDE_DARKEN,
+  UNDERSIDE_DEPTH,
+} from '../config';
+import { hash2, makeThicknessTile, makeTile, tileIndex } from './textures';
+
+interface ChunkSprite {
+  ctx: CanvasRenderingContext2D;
+  image: ImageData;
+  texture: Texture;
+  sprite: Sprite;
+}
+
+/** Probes for the felt-pen outline, sorted by distance so the scan can stop at the local thickness. */
+const OUTLINE_PROBES: [dx: number, dy: number, d: number][] = [];
+for (let dy = -OUTLINE_PX; dy <= OUTLINE_PX; dy++) {
+  for (let dx = -OUTLINE_PX; dx <= OUTLINE_PX; dx++) {
+    const d = Math.hypot(dx, dy);
+    if (d > 0 && d <= OUTLINE_PX + 0.5) OUTLINE_PROBES.push([dx, dy, d]);
+  }
+}
+OUTLINE_PROBES.sort((a, b) => a[2] - b[2]);
+
+/** Column scan margin: how far above/below the chunk the lighting looks. */
+const SCAN = Math.max(RIM_LIGHT_DEPTH, UNDERSIDE_DEPTH, CAVITY_SHADOW_DEPTH) + OUTLINE_PX + 1;
+
+/**
+ * Renders the terrain bitmap as one canvas-backed sprite per 64 px chunk:
+ * textured materials, an irregular felt-pen outline, rim light on top faces,
+ * darker undersides, a drop shadow, and holes that show the inside of the cake.
+ */
+export class TerrainView {
+  readonly container = new Container();
+  private readonly chunks = new Map<number, ChunkSprite>();
+  private readonly tiles: (Uint32Array | undefined)[] = [];
+  private readonly thickness = makeThicknessTile(OUTLINE_PX);
+  /** The map as loaded: pixels solid here but empty now are cavities. */
+  private readonly original: Uint8Array;
+  // Per-column scratch: distance to the nearest empty pixel above / below, and to solid above.
+  private readonly solidUp = new Int16Array(CHUNK_SIZE);
+  private readonly solidDown = new Int16Array(CHUNK_SIZE);
+  private readonly emptyUp = new Int16Array(CHUNK_SIZE);
+
+  constructor(
+    private readonly bitmap: TerrainBitmap,
+    materials: MaterialRegistry,
+  ) {
+    this.original = bitmap.data.slice();
+    materials.byId.forEach((m, id) => {
+      if (m && id !== MATERIAL_EMPTY) this.tiles[id] = makeTile(parseHexColor(m.color), m.texture);
+    });
+  }
+
+  /**
+   * Keeps the "as loaded" copy in sync with pieces that move: a detached chunk takes its
+   * pixels away (the gap shows the background, not the inside of the cake), and a merged
+   * chunk brings them back. Call after applying the event to the bitmap.
+   */
+  onEvent(ev: TerrainEvent): void {
+    if (ev.kind === 'op') return;
+    const cells = rleDecode(ev.kind === 'detach' ? ev.mask : ev.patch, ev.w * ev.h);
+    const { width, height } = this.bitmap;
+    for (let ly = 0; ly < ev.h; ly++) {
+      for (let lx = 0; lx < ev.w; lx++) {
+        const v = cells[ly * ev.w + lx];
+        const x = ev.x + lx;
+        const y = ev.y + ly;
+        if (v === MATERIAL_EMPTY || x < 0 || y < 0 || x >= width || y >= height) continue;
+        const i = y * width + x;
+        this.original[i] = ev.kind === 'detach' ? MATERIAL_EMPTY : this.bitmap.data[i];
+      }
+    }
+  }
+
+  /**
+   * Draws a falling chunk's mask with the same textures (sampled at its original map
+   * position, so it looks unchanged at the moment it breaks off) and a felt outline.
+   */
+  makeChunkTexture(mask: Uint8Array, w: number, h: number, ox: number, oy: number): Texture {
+    const pad = 1;
+    const canvas = document.createElement('canvas');
+    canvas.width = w + pad * 2;
+    canvas.height = h + pad * 2;
+    const ctx = canvas.getContext('2d')!;
+    const image = ctx.createImageData(canvas.width, canvas.height);
+    const out = image.data;
+    const solid = (x: number, y: number): boolean => x >= 0 && y >= 0 && x < w && y < h && mask[y * w + x] !== MATERIAL_EMPTY;
+    const or = OUTLINE_COLOR >> 16;
+    const og = (OUTLINE_COLOR >> 8) & 0xff;
+    const ob = OUTLINE_COLOR & 0xff;
+    for (let y = 0; y < h; y++) {
+      for (let x = 0; x < w; x++) {
+        const m = mask[y * w + x];
+        if (m === MATERIAL_EMPTY) continue;
+        const o = ((y + pad) * canvas.width + (x + pad)) * 4;
+        const t = this.thickness[tileIndex(ox + x, oy + y)];
+        let edge = false;
+        for (const [dx, dy, d] of OUTLINE_PROBES) {
+          if (d > t) break;
+          if (!solid(x + dx, y + dy)) {
+            edge = true;
+            break;
+          }
+        }
+        out[o + 3] = 255;
+        if (edge) {
+          out[o] = or;
+          out[o + 1] = og;
+          out[o + 2] = ob;
+          continue;
+        }
+        const c = this.tiles[m]![tileIndex(ox + x, oy + y)];
+        out[o] = c >> 16;
+        out[o + 1] = (c >> 8) & 0xff;
+        out[o + 2] = c & 0xff;
+      }
+    }
+    ctx.putImageData(image, 0, 0);
+    return new Texture({ source: new CanvasSource({ resource: canvas, scaleMode: 'linear' }) });
+  }
+
+  /** Redraws every chunk flagged dirty in the bitmap. */
+  update(): void {
+    for (const index of this.bitmap.takeDirtyChunks()) this.redraw(index);
+  }
+
+  private redraw(index: number): void {
+    const { x0, y0, x1, y1 } = this.bitmap.chunkRect(index);
+    const bm = this.bitmap;
+    const { width, height, data } = bm;
+    const original = this.original;
+
+    let any = false;
+    for (let y = y0; y < y1 && !any; y++) {
+      for (let x = x0; x < x1; x++) {
+        const i = y * width + x;
+        // Anything solid, any cavity, or any solid casting a shadow into this chunk needs drawing.
+        if (data[i] !== MATERIAL_EMPTY || original[i] !== MATERIAL_EMPTY) {
+          any = true;
+          break;
+        }
+      }
+    }
+    if (!any) any = this.receivesShadow(x0, y0, x1, y1);
+    let chunk = this.chunks.get(index);
+    if (!any) {
+      if (chunk) chunk.sprite.visible = false;
+      return;
+    }
+    chunk ??= this.createChunk(index, x0, y0);
+    chunk.sprite.visible = true;
+
+    const out = chunk.image.data;
+    out.fill(0);
+    const or = OUTLINE_COLOR >> 16;
+    const og = (OUTLINE_COLOR >> 8) & 0xff;
+    const ob = OUTLINE_COLOR & 0xff;
+    const { solidUp, solidDown, emptyUp } = this;
+
+    for (let x = x0; x < x1; x++) {
+      this.scanColumn(x, y0, y1);
+      for (let y = y0; y < y1; y++) {
+        const i = y * width + x;
+        const o = ((y - y0) * CHUNK_SIZE + (x - x0)) * 4;
+        const mat = data[i];
+        const t = this.thickness[tileIndex(x, y)];
+
+        if (mat !== MATERIAL_EMPTY) {
+          let edge = false;
+          for (const [dx, dy, d] of OUTLINE_PROBES) {
+            if (d > t) break;
+            const nx = x + dx;
+            const ny = y + dy;
+            if (nx >= 0 && ny >= 0 && nx < width && ny < height && data[ny * width + nx] === MATERIAL_EMPTY) {
+              edge = true;
+              break;
+            }
+          }
+          out[o + 3] = 255;
+          if (edge) {
+            // Felt pen: slightly uneven ink.
+            const ink = 1 + (hash2(x >> 1, y >> 1, 99) - 0.5) * 0.35;
+            out[o] = or * ink;
+            out[o + 1] = og * ink;
+            out[o + 2] = ob * ink;
+            continue;
+          }
+          const c = this.tiles[mat]![tileIndex(x, y)];
+          let r = c >> 16;
+          let g = (c >> 8) & 0xff;
+          let b = c & 0xff;
+          const up = solidUp[y - y0];
+          const down = solidDown[y - y0];
+          if (up <= t + RIM_LIGHT_DEPTH) {
+            // Rim light on top faces, strongest right under the outline.
+            const k = RIM_LIGHT_WHITE * (1 - (up - t) / (RIM_LIGHT_DEPTH + 1));
+            r += (255 - r) * k;
+            g += (255 - g) * k;
+            b += (255 - b) * k;
+          } else if (down <= t + UNDERSIDE_DEPTH) {
+            r *= UNDERSIDE_DARKEN;
+            g *= UNDERSIDE_DARKEN;
+            b *= UNDERSIDE_DARKEN;
+          }
+          out[o] = r;
+          out[o + 1] = g;
+          out[o + 2] = b;
+          continue;
+        }
+
+        const shadowX = x - DROP_SHADOW_DX;
+        const shadowY = y - DROP_SHADOW_DY;
+        const inShadow = shadowX >= 0 && shadowY >= 0 && data[shadowY * width + shadowX] !== MATERIAL_EMPTY;
+        const orig = original[i];
+        if (orig !== MATERIAL_EMPTY) {
+          // Cavity: the inside of the cake, darker, shaded under the ceiling.
+          const c = this.tiles[orig]![tileIndex(x, y)];
+          let k = CAVITY_DARKEN;
+          const e = emptyUp[y - y0];
+          if (e < CAVITY_SHADOW_DEPTH) k *= 0.6 + (0.4 * e) / CAVITY_SHADOW_DEPTH;
+          if (inShadow) k *= 0.85;
+          out[o] = (c >> 16) * k;
+          out[o + 1] = ((c >> 8) & 0xff) * k * 0.95;
+          out[o + 2] = (c & 0xff) * k * 0.9;
+          out[o + 3] = 255;
+        } else if (inShadow) {
+          out[o] = or;
+          out[o + 1] = og;
+          out[o + 2] = ob;
+          out[o + 3] = DROP_SHADOW_ALPHA;
+        }
+      }
+    }
+    chunk.ctx.putImageData(chunk.image, 0, 0);
+    chunk.texture.source.update();
+  }
+
+  /** Fills the per-column distance tables for column x over rows [y0, y1). */
+  private scanColumn(x: number, y0: number, y1: number): void {
+    const { width, height, data } = this.bitmap;
+    const solid = (y: number): boolean => y >= 0 && y < height && data[y * width + x] !== MATERIAL_EMPTY;
+    let runSolid = 0;
+    let sinceSolid = SCAN;
+    for (let y = y0 - SCAN; y < y1; y++) {
+      if (solid(y)) {
+        runSolid++;
+        sinceSolid = 0;
+      } else {
+        runSolid = 0;
+        sinceSolid++;
+      }
+      if (y >= y0) {
+        this.solidUp[y - y0] = runSolid;
+        this.emptyUp[y - y0] = sinceSolid;
+      }
+    }
+    runSolid = 0;
+    for (let y = y1 + SCAN - 1; y >= y0; y--) {
+      runSolid = solid(y) ? runSolid + 1 : 0;
+      if (y < y1) this.solidDown[y - y0] = runSolid;
+    }
+  }
+
+  private receivesShadow(x0: number, y0: number, x1: number, y1: number): boolean {
+    const bm = this.bitmap;
+    for (let y = y0 - DROP_SHADOW_DY; y < y1 - DROP_SHADOW_DY; y++) {
+      for (let x = x0 - DROP_SHADOW_DX; x < x1 - DROP_SHADOW_DX; x++) if (bm.isSolid(x, y)) return true;
+    }
+    return false;
+  }
+
+  private createChunk(index: number, x0: number, y0: number): ChunkSprite {
+    const canvas = document.createElement('canvas');
+    canvas.width = CHUNK_SIZE;
+    canvas.height = CHUNK_SIZE;
+    const ctx = canvas.getContext('2d')!;
+    const image = ctx.createImageData(CHUNK_SIZE, CHUNK_SIZE);
+    const texture = new Texture({ source: new CanvasSource({ resource: canvas, scaleMode: 'nearest' }) });
+    const sprite = new Sprite(texture);
+    sprite.position.set(x0, y0);
+    this.container.addChild(sprite);
+    const chunk = { ctx, image, texture, sprite };
+    this.chunks.set(index, chunk);
+    return chunk;
+  }
+}

+ 155 - 0
src/renderer/terrain/textures.ts

@@ -0,0 +1,155 @@
+// Procedural, tileable material textures. Purely cosmetic (client only).
+// Every texture is a TILE×TILE RGB tile sampled in world coordinates, so patterns are seamless across chunks.
+
+export const TILE = 128;
+const MASK = TILE - 1;
+
+/** Integer hash in [0, 1). */
+export function hash2(x: number, y: number, seed = 0): number {
+  let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263) ^ Math.imul(seed, 1442695041);
+  h = Math.imul(h ^ (h >>> 13), 1274126177);
+  return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
+}
+
+const smooth = (t: number): number => t * t * (3 - 2 * t);
+
+/** Periodic value noise: `cells` lattice cells across the tile. */
+function valueNoise(x: number, y: number, cells: number, seed: number): number {
+  const s = TILE / cells;
+  const fx = x / s;
+  const fy = y / s;
+  const ix = Math.floor(fx);
+  const iy = Math.floor(fy);
+  const tx = smooth(fx - ix);
+  const ty = smooth(fy - iy);
+  const w = (i: number): number => ((i % cells) + cells) % cells;
+  const a = hash2(w(ix), w(iy), seed);
+  const b = hash2(w(ix + 1), w(iy), seed);
+  const c = hash2(w(ix), w(iy + 1), seed);
+  const d = hash2(w(ix + 1), w(iy + 1), seed);
+  return a + (b - a) * tx + (c - a) * ty + (a - b - c + d) * tx * ty;
+}
+
+/** Periodic Worley noise: distance (in cell units) to the nearest jittered point, and that point's id. */
+function worley(x: number, y: number, cells: number, seed: number): { d: number; id: number } {
+  const s = TILE / cells;
+  const fx = x / s;
+  const fy = y / s;
+  const ix = Math.floor(fx);
+  const iy = Math.floor(fy);
+  let best = Infinity;
+  let id = 0;
+  for (let oy = -1; oy <= 1; oy++) {
+    for (let ox = -1; ox <= 1; ox++) {
+      const cx = ix + ox;
+      const cy = iy + oy;
+      const wx = ((cx % cells) + cells) % cells;
+      const wy = ((cy % cells) + cells) % cells;
+      const px = cx + 0.15 + 0.7 * hash2(wx, wy, seed);
+      const py = cy + 0.15 + 0.7 * hash2(wx, wy, seed + 1);
+      const d = Math.hypot(px - fx, py - fy);
+      if (d < best) {
+        best = d;
+        id = wx * 131 + wy;
+      }
+    }
+  }
+  return { d: best, id };
+}
+
+/** Returns a brightness multiplier and an optional tint toward white (0..1) for a texture at tile coords. */
+type Shader = (x: number, y: number) => [k: number, white: number];
+
+const shaders: Record<string, Shader> = {
+  sponge: (x, y) => {
+    const grain = (hash2(x, y, 3) - 0.5) * 0.08;
+    const soft = (valueNoise(x, y, 8, 4) - 0.5) * 0.12;
+    const { d } = worley(x, y, 16, 5);
+    if (d < 0.22) return [0.68 + d, 0]; // air bubble
+    if (d < 0.3) return [1.1, 0.05]; // bubble rim catches the light
+    return [1 + grain + soft, 0];
+  },
+  'soaked-sponge': (x, y) => {
+    const [k] = shaders.sponge(x, y);
+    const wet = valueNoise(x, y, 4, 9);
+    return [k * 0.9, wet > 0.72 ? 0.18 : 0];
+  },
+  chocolate: (x, y) => {
+    const u = x % 32;
+    const v = y % 32;
+    const n = (hash2(x, y, 7) - 0.5) * 0.04;
+    if (u < 3 || v < 3) return [1.35 + n, 0.05]; // bevel highlight
+    if (u > 28 || v > 28) return [0.7 + n, 0]; // bevel shadow
+    const gloss = (u + v) % 64 > 40 && (u + v) % 64 < 46 ? 0.12 : 0;
+    return [1 + n, gloss];
+  },
+  cream: (x, y) => {
+    const n = valueNoise(x, y, 4, 11);
+    const swirl = Math.sin((x + n * 40) / 9 + Math.sin(y / 11) * 2);
+    return [0.9 + n * 0.1 - (swirl > 0.85 ? 0.08 : 0), 0];
+  },
+  jelly: (x, y) => {
+    const deep = valueNoise(x, y, 4, 13);
+    const { d } = worley(x, y, 8, 14);
+    const stripe = (x - y + TILE * 4) % 64 < 6 ? 0.25 : 0;
+    if (d > 0.12 && d < 0.17) return [1.15, 0.35]; // tiny bubble ring
+    return [0.82 + deep * 0.25, stripe];
+  },
+  biscuit: (x, y) => {
+    const grain = (hash2(x, y, 17) - 0.5) * 0.16;
+    const row = Math.floor(y / 32);
+    const hx = (x + (row % 2) * 16) % 32;
+    const hy = y % 32;
+    const dx = hx - 16;
+    const dy = hy - 16;
+    const r2 = dx * dx + dy * dy;
+    if (r2 < 9) return [0.6, 0]; // docking hole
+    if (r2 < 16 && dy > 0) return [1.15, 0.05];
+    return [1 + grain + (valueNoise(x, y, 8, 18) - 0.5) * 0.1, 0];
+  },
+  caramel: (x, y) => {
+    const streak = valueNoise(x, y * 4, 4, 19);
+    return [0.85 + streak * 0.3, streak > 0.8 ? 0.15 : 0];
+  },
+  wool: (x, y) => {
+    const u = x % 10;
+    const v = (y + (u < 5 ? u : 9 - u)) % 10;
+    return [v < 2 ? 0.75 : 1 + (hash2(x, y, 21) - 0.5) * 0.1, 0];
+  },
+  plate: (x, y) => {
+    const band = y % 14;
+    if (band < 2) return [1.25, 0.3];
+    if (band === 13) return [0.85, 0];
+    return [1.05 + (valueNoise(x, y, 4, 23) - 0.5) * 0.06, 0.1];
+  },
+};
+
+const fallback: Shader = (x, y) => [0.95 + hash2(x, y, 1) * 0.1, 0];
+
+/** Packed 0xRRGGBB tile for a material. */
+export function makeTile(color: number, texture: string): Uint32Array {
+  const shade = shaders[texture] ?? fallback;
+  const r = color >> 16;
+  const g = (color >> 8) & 0xff;
+  const b = color & 0xff;
+  const tile = new Uint32Array(TILE * TILE);
+  for (let y = 0; y < TILE; y++) {
+    for (let x = 0; x < TILE; x++) {
+      const [k, white] = shade(x, y);
+      const c = (v: number): number => Math.max(0, Math.min(255, Math.round(v * k * (1 - white) + 255 * white)));
+      tile[y * TILE + x] = (c(r) << 16) | (c(g) << 8) | c(b);
+    }
+  }
+  return tile;
+}
+
+/** Felt-pen outline thickness at a world pixel: 2..max, varying slowly along the line. */
+export function makeThicknessTile(max: number): Uint8Array {
+  const tile = new Uint8Array(TILE * TILE);
+  for (let y = 0; y < TILE; y++) {
+    for (let x = 0; x < TILE; x++) tile[y * TILE + x] = 2 + Math.round(valueNoise(x, y, 8, 31) * (max - 2));
+  }
+  return tile;
+}
+
+export const tileIndex = (x: number, y: number): number => (y & MASK) * TILE + (x & MASK);

+ 92 - 0
src/renderer/ui/menu.ts

@@ -0,0 +1,92 @@
+import { fr } from '../i18n/fr';
+
+export interface MenuChoice {
+  name: string;
+  characterId: string;
+}
+
+const NAME_KEY = 'gouter.name';
+const CHARACTER_KEY = 'gouter.character';
+
+function load(key: string): string | null {
+  try {
+    return localStorage.getItem(key);
+  } catch {
+    return null;
+  }
+}
+
+function save(key: string, value: string): void {
+  try {
+    localStorage.setItem(key, value);
+  } catch {
+    // Storage unavailable: the menu just won't remember the choice.
+  }
+}
+
+/** Title screen: nickname + character choice. `previews` maps character ids to a rendered portrait. */
+export function showMenu(characterIds: string[], defaultId: string, previews: Map<string, HTMLCanvasElement>): Promise<MenuChoice> {
+  const root = document.createElement('div');
+  root.id = 'menu';
+  root.innerHTML = `
+    <div class="menu-card">
+      <h1>${fr.title}</h1>
+      <p class="subtitle">${fr.subtitle}</p>
+      <label class="name-label">${fr.nameLabel}
+        <input id="menu-name" maxlength="24" autocomplete="off" spellcheck="false" />
+      </label>
+      <h2>${fr.chooseCharacter}</h2>
+      <div class="characters"></div>
+      <button id="menu-play">${fr.play}</button>
+    </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;
+  if (!characterIds.includes(selected)) selected = defaultId;
+
+  const list = root.querySelector<HTMLDivElement>('.characters')!;
+  const cards = new Map<string, HTMLButtonElement>();
+  for (const id of characterIds) {
+    const text = fr.characters[id] ?? { name: id, desc: '' };
+    const card = document.createElement('button');
+    card.className = 'character';
+    card.type = 'button';
+    const preview = previews.get(id);
+    if (preview) {
+      preview.className = 'portrait';
+      card.appendChild(preview);
+    }
+    const name = document.createElement('strong');
+    name.textContent = text.name;
+    const desc = document.createElement('span');
+    desc.textContent = text.desc;
+    card.append(name, desc);
+    card.addEventListener('click', () => select(id));
+    card.addEventListener('dblclick', () => play());
+    list.appendChild(card);
+    cards.set(id, card);
+  }
+
+  function select(id: string): void {
+    selected = id;
+    for (const [cid, card] of cards) card.classList.toggle('selected', cid === id);
+  }
+  select(selected);
+
+  let resolve!: (choice: MenuChoice) => void;
+  const done = new Promise<MenuChoice>((r) => (resolve = r));
+  function play(): void {
+    const name = nameInput.value.trim() || fr.defaultName;
+    save(NAME_KEY, name);
+    save(CHARACTER_KEY, selected);
+    root.remove();
+    resolve({ name, characterId: selected });
+  }
+  root.querySelector('#menu-play')!.addEventListener('click', play);
+  nameInput.addEventListener('keydown', (e) => {
+    if (e.key === 'Enter') play();
+  });
+  return done;
+}

+ 123 - 0
src/shared/characters/Castor.ts

@@ -0,0 +1,123 @@
+import { Button, isDown } from '../input';
+import { Anim, Character, type CharacterContext } from './Character';
+
+/**
+ * Castor Glouton.
+ * - Attack: a bite in the aim direction (knocks back players, nibbles terrain).
+ * - Special (held): eats through the terrain toward the aim, pushing through his own tunnel
+ *   as long as there is something in front of him to eat.
+ * - Flaw: every pixel eaten makes him bigger, slower and heavier; past `heavySize`
+ *   the floor gives way under him. He slowly digests back to his base size.
+ */
+export class Castor extends Character {
+  private eatTimer = 0;
+  private crushTimer = 0;
+  private biteTimer = 0;
+  private eating = false;
+  /** Seconds of burrowing thrust left: only renewed while there is actually something to eat. */
+  private burrowTime = 0;
+
+  onAttack(ctx: CharacterContext): void {
+    const p = this.stats.params;
+    const [dx, dy] = aimDir(ctx);
+    const reach = this.size + p.biteReach;
+    const bx = ctx.x + dx * reach;
+    const by = ctx.y + dy * reach;
+    ctx.hit(bx, by, p.biteRadius, p.biteKnockback, p.biteStun);
+    ctx.emitOp({ kind: 'carve', x: Math.round(bx), y: Math.round(by), r: Math.round(p.biteRadius), power: Math.round(p.bitePower) }, (n) =>
+      this.grow(ctx, n),
+    );
+    this.biteTimer = p.biteAnim;
+  }
+
+  onSpecial(_ctx: CharacterContext): void {}
+
+  override onTick(ctx: CharacterContext, dt: number): void {
+    const p = this.stats.params;
+    this.biteTimer = Math.max(0, this.biteTimer - dt);
+    this.eating = isDown(ctx.input, Button.Special);
+
+    this.burrowTime = Math.max(0, this.burrowTime - dt);
+    if (this.eating) {
+      const [dx, dy] = aimDir(ctx);
+      // Burrow toward the aim while there is cake in the way; chewing on thin air does not fly.
+      if (this.burrowTime > 0) {
+        const speed = p.eatSpeed * this.mobility();
+        ctx.setVelocity(dx * speed, dy * speed);
+      }
+      this.eatTimer -= dt;
+      if (this.eatTimer <= 0) {
+        this.eatTimer = p.eatInterval;
+        const front = this.size * p.eatFrontFactor;
+        ctx.emitOp(
+          {
+            kind: 'carve',
+            x: Math.round(ctx.x + dx * front),
+            y: Math.round(ctx.y + dy * front),
+            r: Math.round(this.size * p.eatRadiusFactor),
+            power: Math.round(p.eatPower),
+          },
+          (n) => {
+            this.grow(ctx, n);
+            if (n >= p.burrowMinPixels) this.burrowTime = p.burrowGrace;
+          },
+        );
+        // Also clear around the body so tunnel walls never pinch him when he turns or grows.
+        ctx.emitOp(
+          { kind: 'carve', x: Math.round(ctx.x), y: Math.round(ctx.y), r: Math.round(this.size * p.eatClearFactor), power: Math.round(p.eatPower) },
+          (n) => this.grow(ctx, n),
+        );
+      }
+    } else {
+      this.eatTimer = 0;
+    }
+
+    // Digestion.
+    if (!this.eating && this.size > this.stats.size) this.setSize(ctx, Math.max(this.stats.size, this.size - p.digestRate * dt));
+
+    // Too fat: the floor gives way.
+    if (this.size >= p.heavySize && ctx.grounded) {
+      this.crushTimer -= dt;
+      if (this.crushTimer <= 0) {
+        this.crushTimer = p.crushInterval;
+        ctx.emitOp({
+          kind: 'carve',
+          x: Math.round(ctx.x),
+          y: Math.round(ctx.y + this.size),
+          r: Math.round(this.size * 0.8),
+          power: Math.round((this.size - p.heavySize) * p.crushPowerPerPx),
+        });
+      }
+    } else {
+      this.crushTimer = p.crushInterval;
+    }
+  }
+
+  override anim(): number {
+    return (this.biteTimer > 0 ? Anim.Attack : 0) | (this.eating ? Anim.Special : 0);
+  }
+
+  override mobility(): number {
+    const p = this.stats.params;
+    const t = (this.size - this.stats.size) / (p.maxSize - this.stats.size);
+    return 1 - t * (1 - p.minMobility);
+  }
+
+  private grow(ctx: CharacterContext, eaten: number): void {
+    if (eaten <= 0) return;
+    const p = this.stats.params;
+    this.setSize(ctx, Math.min(p.maxSize, this.size + eaten * p.growthPerPixel));
+  }
+
+  private setSize(ctx: CharacterContext, size: number): void {
+    this.size = size;
+    ctx.setSize(size);
+  }
+}
+
+function aimDir(ctx: CharacterContext): [number, number] {
+  const dx = ctx.input.aimX - ctx.x;
+  const dy = ctx.input.aimY - ctx.y;
+  const len = Math.hypot(dx, dy);
+  return len < 1 ? [ctx.facing, 0] : [dx / len, dy / len];
+}