Back to Dine and Dash
SOURCE / PINNED RELEASE

Made of little things.

Dine and Dash

Release
32e37bfe002f…
Author-recorded commit
dce273752e95…
License
LICENSE
Author’s source reference
nostr://npub1ny7l44q0au5xkv3mjgt6zks5f0e2ydefslq4nhh2ve2xypgc2mqqat8qx2/wss%3A%2F%2Fgit.napplet.soy%2F/n-2f845a2d471

Archive hash verified: 34114f86c7bcd52e…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/render3d.ts
import {
  BoxGeometry,
  CanvasTexture,
  CylinderGeometry,
  DirectionalLight,
  DoubleSide,
  Fog,
  Group,
  HemisphereLight,
  Mesh,
  MeshBasicMaterial,
  MeshLambertMaterial,
  PerspectiveCamera,
  PlaneGeometry,
  RepeatWrapping,
  SRGBColorSpace,
  Scene,
  SphereGeometry,
  Sprite,
  SpriteMaterial,
  Vector3,
  WebGLRenderer,
  type Material,
} from 'three';
import { COLS, HUD_H, ROWS, VIEW_H, VIEW_W, tileAt, type Dir, type GameView, type Mover, type Project } from './game.js';

/**
 * The diner seen through your own eyes. Each tile of the map is one step wide.
 * Tile (x, y) sits at x across and z = y deep; up is height.
 */

const WALL_H = 1.6;
/** How high your eyes are (stickmen are about 1 tall). */
const EYES = 0.82;
const WAITER_INK = '#151515';
const DASHER_GREEN = '#2fbf4f';
const HOODIE = '#f4f4f4';

/** Which way to turn for each facing. Facing up looks along -z. */
const YAW: Record<Dir, number> = { up: 0, left: Math.PI / 2, down: Math.PI, right: -Math.PI / 2 };

/** Make a texture by drawing on a little canvas. */
function paint(width: number, height: number, draw: (ctx: CanvasRenderingContext2D) => void): CanvasTexture {
  const canvas = document.createElement('canvas');
  canvas.width = width;
  canvas.height = height;
  draw(canvas.getContext('2d')!);
  const texture = new CanvasTexture(canvas);
  texture.colorSpace = SRGBColorSpace;
  texture.anisotropy = 4;
  return texture;
}

/** Turn smoothly from one angle to another, the short way round. */
function turnToward(from: number, to: number, amount: number): number {
  let diff = to - from;
  while (diff > Math.PI) diff -= Math.PI * 2;
  while (diff < -Math.PI) diff += Math.PI * 2;
  return from + diff * amount;
}

const EMOJI_FONT = '"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", system-ui, sans-serif';

/** A stickman built from thin tubes, with arms and legs that swing when they walk. */
class Stickman {
  group = new Group();
  private legs: Group[] = [];
  private arms: Group[] = [];
  private yaw = 0;

  constructor(kind: 'waiter' | 'dasher') {
    const color = kind === 'waiter' ? WAITER_INK : DASHER_GREEN;
    const ink = new MeshLambertMaterial({ color });
    const white = new MeshLambertMaterial({ color: HOODIE });
    const limb = new CylinderGeometry(0.028, 0.028, 1, 8);

    const head = new Mesh(new SphereGeometry(0.1, 16, 12), ink);
    head.position.y = 0.88;
    const body = new Mesh(limb, kind === 'dasher' ? white : ink);
    body.scale.y = 0.34;
    body.position.y = 0.62;
    this.group.add(head, body);

    // Eyes on the front of the head, so you can tell which way they're looking.
    const eye = new MeshBasicMaterial({ color: kind === 'waiter' ? '#ffffff' : '#111111' });
    for (const side of [-1, 1]) {
      const dot = new Mesh(new SphereGeometry(0.018, 8, 6), eye);
      dot.position.set(side * 0.035, 0.9, -0.09);
      this.group.add(dot);
    }

    if (kind === 'waiter') {
      // A white bow tie under the chin.
      for (const side of [-1, 1]) {
        const wing = new Mesh(new BoxGeometry(0.09, 0.06, 0.02), white);
        wing.position.set(side * 0.045, 0.765, -0.04);
        wing.rotation.z = side * 0.35;
        this.group.add(wing);
      }
      const knot = new Mesh(new SphereGeometry(0.018, 8, 6), white);
      knot.position.set(0, 0.765, -0.05);
      this.group.add(knot);
    } else {
      // A white hoodie: a chunky body and a hood behind the head.
      const hoodie = new Mesh(new BoxGeometry(0.2, 0.3, 0.12), white);
      hoodie.position.y = 0.63;
      const hood = new Mesh(
        new SphereGeometry(0.12, 16, 12, 0, Math.PI, 0, Math.PI * 0.75),
        new MeshLambertMaterial({ color: HOODIE, side: DoubleSide }),
      );
      hood.position.set(0, 0.885, 0.01);
      this.group.add(hoodie, hood);
    }

    // Legs and arms hang from pivots at the hips and shoulders.
    for (const side of [-1, 1]) {
      const hip = new Group();
      hip.position.set(side * 0.05, 0.45, 0);
      const leg = new Mesh(limb, ink);
      leg.scale.y = 0.45;
      leg.position.y = -0.225;
      hip.add(leg);
      this.legs.push(hip);

      const shoulder = new Group();
      shoulder.position.set(side * (kind === 'dasher' ? 0.12 : 0.04), 0.76, 0);
      shoulder.rotation.z = side * 0.25;
      const arm = new Mesh(limb, ink);
      arm.scale.y = 0.36;
      arm.position.y = -0.18;
      shoulder.add(arm);
      if (kind === 'dasher') {
        const sleeve = new Mesh(new CylinderGeometry(0.05, 0.05, 0.18, 8), white);
        sleeve.position.y = -0.08;
        shoulder.add(sleeve);
      }
      this.arms.push(shoulder);
      this.group.add(hip, shoulder);
    }
  }

  place(view: GameView, m: Mover): void {
    const pos = view.posOf(m);
    const walking = m.t < 1 && (m.x !== m.fromX || m.y !== m.fromY);
    const swing = walking ? Math.sin(m.t * Math.PI * 2) * 0.6 : 0;
    this.group.position.set(pos.x, walking ? Math.abs(Math.sin(m.t * Math.PI * 2)) * 0.03 : 0, pos.y);
    this.yaw = turnToward(this.yaw, YAW[m.facing], 0.25);
    this.group.rotation.y = this.yaw;
    this.legs[0].rotation.x = swing;
    this.legs[1].rotation.x = -swing;
    this.arms[0].rotation.x = -swing;
    this.arms[1].rotation.x = swing;
  }
}

export class Renderer3D {
  private renderer: WebGLRenderer;
  private scene = new Scene();
  private camera = new PerspectiveCamera(70, 1, 0.05, 40);
  private pitch = 0;
  private yawReady = false;
  private plates = new Map<string, Sprite>();
  private flames: Mesh[] = [];
  /** Each table's candle, hidden while there's food on it. */
  private candles = new Map<string, Group>();
  private lightTiles: Mesh[] = [];
  private lightGeometry = new PlaneGeometry(1, 1);
  private seenMaterial = new MeshBasicMaterial({ color: '#ffd640', transparent: true, opacity: 0.35, depthWrite: false });
  private chaseMaterial = new MeshBasicMaterial({ color: '#ff3c3c', transparent: true, opacity: 0.35, depthWrite: false });
  private waiters: Stickman[] = [];
  private dashers: Stickman[] = [];
  private doorMaterial: MeshLambertMaterial;
  private doorLocked: CanvasTexture;
  private doorOpen: CanvasTexture;
  private doorShowsOpen = false;
  private sign: MeshBasicMaterial | null = null;
  /** Everything in the room that gets rebuilt when a new game makes a new room. */
  private room = new Group();
  private builtMap: readonly string[] | null = null;

  constructor(canvas: HTMLCanvasElement) {
    this.renderer = new WebGLRenderer({ canvas, antialias: true });
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
    this.scene.fog = new Fog('#2a1810', 6, 16);
    this.renderer.setClearColor('#2a1810');
    const sun = new DirectionalLight('#fff1d6', 1.2);
    sun.position.set(3, 8, 4);
    this.scene.add(new HemisphereLight('#fff4e0', '#6a4530', 2.2), sun);

    this.doorLocked = this.paintDoor(false);
    this.doorOpen = this.paintDoor(true);
    this.doorMaterial = new MeshLambertMaterial({ map: this.doorLocked });
    this.scene.add(this.room);
  }

  /** The picture's size, in the same units the belly bar uses. */
  private viewW = VIEW_W;
  private viewH = VIEW_H;

  resize(cssWidth: number, cssHeight: number, viewW: number, viewH: number): void {
    this.viewW = viewW;
    this.viewH = viewH;
    this.renderer.setSize(cssWidth, cssHeight, false);
    this.camera.aspect = cssWidth / Math.max(1, cssHeight);
    this.camera.updateProjectionMatrix();
  }

  render(view: GameView): void {
    if (view.map !== this.builtMap) this.rebuildRoom(view.map);
    this.syncFood(view);
    this.syncPeople(view);
    this.syncLight(view);
    if (view.doorOpen !== this.doorShowsOpen) {
      this.doorShowsOpen = view.doorOpen;
      this.doorMaterial.map = view.doorOpen ? this.doorOpen : this.doorLocked;
      this.doorMaterial.emissive.set(view.doorOpen ? '#1a6a3a' : '#000000');
      this.doorMaterial.needsUpdate = true;
    }
    this.flames.forEach((flame, i) => flame.scale.setScalar(0.8 + Math.sin(view.clock * 12 + i * 2) * 0.25));
    if (this.sign) this.sign.opacity = 0.85 + Math.sin(view.clock * 3) * 0.15;

    // Look through your own eyes, with a little bounce as you walk.
    const pos = view.posOf(view.player);
    if (!this.yawReady) {
      this.pitch = 0;
      this.yawReady = true;
    }
    const bob = Math.abs(Math.sin(view.stride * 3)) * 0.03;
    this.camera.position.set(pos.x, EYES + bob, pos.y);
    this.camera.rotation.set(this.pitch, view.angle, 0, 'YXZ');
    this.renderer.render(this.scene, this.camera);
  }

  /** Snap the view to wherever you're facing (used when a new game starts). */
  snapView(): void {
    this.yawReady = false;
  }

  /** Look up and down with the mouse (or a finger). */
  pitchBy(dy: number): void {
    this.pitch = Math.max(-0.7, Math.min(0.7, this.pitch - dy));
  }

  /** Where a spot in the diner shows up on the picture, in the same units the belly bar uses. */
  project: Project = (x, height, y) => {
    const point = new Vector3(x, height, y);
    if (point.clone().applyMatrix4(this.camera.matrixWorldInverse).z > -0.1) return null;
    point.project(this.camera);
    return { x: ((point.x + 1) / 2) * this.viewW, y: HUD_H + ((1 - point.y) / 2) * (this.viewH - HUD_H) };
  };

  // ---------- building the diner ----------

  /** Clear away the old room (and its food) and build the new one. */
  private rebuildRoom(map: readonly string[]): void {
    const keep = new Set<unknown>([this.doorMaterial, this.doorLocked, this.doorOpen]);
    this.room.traverse((part) => {
      if (!(part instanceof Mesh)) return;
      part.geometry.dispose();
      for (const material of [part.material].flat() as MeshLambertMaterial[]) {
        if (keep.has(material)) continue;
        if (material.map && !keep.has(material.map)) material.map.dispose();
        material.dispose();
      }
    });
    this.room.clear();
    for (const sprite of this.plates.values()) {
      this.scene.remove(sprite);
      sprite.material.dispose();
    }
    this.plates.clear();
    this.flames = [];
    this.candles.clear();
    this.builtMap = map;
    this.buildRoom();
  }

  private buildRoom(): void {
    const wallpaper = paint(64, 128, (ctx) => {
      ctx.fillStyle = '#7d2f3a';
      ctx.fillRect(0, 0, 64, 128);
      ctx.fillStyle = '#8f3a45';
      for (let i = 0; i < 64; i += 16) ctx.fillRect(i, 0, 8, 128);
      // Wooden panels along the bottom.
      ctx.fillStyle = '#4a2a1a';
      ctx.fillRect(0, 92, 64, 36);
      ctx.fillStyle = '#5c3622';
      ctx.fillRect(4, 98, 56, 26);
      ctx.fillStyle = '#e9c46a';
      ctx.fillRect(0, 90, 64, 3);
    });
    const wallMaterial = new MeshLambertMaterial({ map: wallpaper });
    const wallGeometry = new BoxGeometry(1, WALL_H, 1);

    const floorTexture = paint(64, 64, (ctx) => {
      for (let row = 0; row < 4; row++) {
        ctx.fillStyle = row % 2 === 0 ? '#b5804f' : '#a97447';
        ctx.fillRect(0, row * 16, 64, 16);
        ctx.fillStyle = 'rgba(60, 30, 10, 0.4)';
        ctx.fillRect(0, row * 16 + 15, 64, 1);
        ctx.fillRect(((row * 23) % 48) + 8, row * 16, 1, 16);
      }
    });
    floorTexture.wrapS = floorTexture.wrapT = RepeatWrapping;
    floorTexture.repeat.set(COLS, ROWS);
    const floor = new Mesh(new PlaneGeometry(COLS, ROWS), new MeshLambertMaterial({ map: floorTexture }));
    floor.rotation.x = -Math.PI / 2;
    floor.position.set(COLS / 2 - 0.5, 0, ROWS / 2 - 0.5);
    const ceiling = new Mesh(new PlaneGeometry(COLS, ROWS), new MeshLambertMaterial({ color: '#5a3a30', side: DoubleSide }));
    ceiling.rotation.x = Math.PI / 2;
    ceiling.position.set(COLS / 2 - 0.5, WALL_H, ROWS / 2 - 0.5);
    this.room.add(floor, ceiling);

    // Round hanging lamps over the room.
    const lampGeometry = new SphereGeometry(0.07, 12, 8);
    const lampMaterial = new MeshBasicMaterial({ color: '#ffe08a' });
    for (let y = 2; y < ROWS - 1; y += 3) {
      for (let x = 2; x < COLS - 1; x += 4) {
        const lamp = new Mesh(lampGeometry, lampMaterial);
        lamp.position.set(x + 0.5, WALL_H - 0.06, y + 0.5);
        this.room.add(lamp);
      }
    }

    const kitchenTops = [0, 1, 2, 3].map((kind) => this.paintKitchenTop(kind));
    const steel = new MeshLambertMaterial({ color: '#b8c4cc' });
    const tableParts = this.makeTableParts();

    for (let y = 0; y < ROWS; y++) {
      for (let x = 0; x < COLS; x++) {
        const c = tileAt(x, y);
        if (c === '#') {
          const wall = new Mesh(wallGeometry, wallMaterial);
          wall.position.set(x, WALL_H / 2, y);
          this.room.add(wall);
        } else if (c === 'K') {
          const top = new MeshLambertMaterial({ map: kitchenTops[x % 4] });
          // Box sides: steel, with the cooking stuff painted on top.
          const counter = new Mesh(new BoxGeometry(1, 0.55, 1), [steel, steel, top, steel, steel, steel]);
          counter.position.set(x, 0.275, y);
          this.room.add(counter);
        } else if (c === 'T' || c === 'F') {
          this.room.add(this.makeTable(x, y, tableParts));
        } else if (c === 'D') {
          // The front door at the back of a little doorway.
          const door = new Mesh(new PlaneGeometry(1, 1.3), this.doorMaterial);
          door.position.set(x, 0.65, y + 0.49);
          door.rotation.y = Math.PI;
          this.room.add(door);
          for (const side of [-1, 1]) {
            const post = new Mesh(new BoxGeometry(0.02, WALL_H, 1), wallMaterial);
            post.position.set(x + side * 0.5, WALL_H / 2, y);
            this.room.add(post);
          }
          const above = new Mesh(new BoxGeometry(1, WALL_H - 1.3, 1), wallMaterial);
          above.position.set(x, 1.3 + (WALL_H - 1.3) / 2, y);
          this.room.add(above);
        }
      }
    }
    this.decorateWalls();
  }

  /** Windows, pictures, wall lamps and the glowing DINER sign. */
  private decorateWalls(): void {
    const windowTexture = paint(64, 64, (ctx) => {
      ctx.fillStyle = '#9fd3f0';
      ctx.fillRect(8, 6, 48, 50);
      ctx.fillStyle = '#ffffff';
      ctx.fillRect(30, 6, 4, 50);
      ctx.fillRect(8, 29, 48, 4);
      ctx.fillStyle = '#e9c46a';
      ctx.fillRect(0, 0, 12, 64);
      ctx.fillRect(52, 0, 12, 64);
    });
    const pictureTexture = paint(64, 48, (ctx) => {
      ctx.fillStyle = '#d4a548';
      ctx.fillRect(0, 0, 64, 48);
      ctx.fillStyle = '#5fa8d3';
      ctx.fillRect(6, 6, 52, 36);
      ctx.fillStyle = '#4caf50';
      ctx.fillRect(6, 28, 52, 14);
      ctx.fillStyle = '#ffd640';
      ctx.beginPath();
      ctx.arc(46, 16, 6, 0, Math.PI * 2);
      ctx.fill();
    });
    const signTexture = paint(256, 64, (ctx) => {
      ctx.fillStyle = '#2a1420';
      ctx.beginPath();
      ctx.roundRect(0, 0, 256, 64, 12);
      ctx.fill();
      ctx.font = 'bold 44px system-ui, sans-serif';
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.shadowColor = '#ff5a8a';
      ctx.shadowBlur = 14;
      ctx.fillStyle = '#ffc8dc';
      ctx.fillText('DINER', 128, 34);
    });
    const windowMaterial = new MeshLambertMaterial({ map: windowTexture, emissive: '#30506a' });
    const pictureMaterial = new MeshLambertMaterial({ map: pictureTexture });
    const lampMaterial = new MeshBasicMaterial({ color: '#ffe08a' });
    this.sign = new MeshBasicMaterial({ map: signTexture, transparent: true });

    const mid = Math.floor(COLS / 2);
    const sign = new Mesh(new PlaneGeometry(1.8, 0.45), this.sign);
    sign.position.set(mid, 1.2, 0.51);
    this.room.add(sign);

    for (let x = 1; x < COLS - 1; x++) {
      if (Math.abs(x - mid) <= 1) continue;
      // Back wall (above the kitchen) and the front wall by the door.
      const back = new Mesh(new PlaneGeometry(0.6, x % 3 === 0 ? 0.6 : 0.45), x % 3 === 0 ? windowMaterial : pictureMaterial);
      back.position.set(x, 1.15, 0.51);
      if (x % 3 !== 2) this.room.add(back);
      if (x % 3 === 0 && Math.abs(x - mid) > 1) {
        const front = new Mesh(new PlaneGeometry(0.6, 0.6), windowMaterial);
        front.position.set(x, 1.0, ROWS - 1.51);
        front.rotation.y = Math.PI;
        this.room.add(front);
      }
    }
    for (let y = 2; y < ROWS - 1; y++) {
      for (const [x, turn] of [[0.51, Math.PI / 2], [COLS - 1.51, -Math.PI / 2]] as const) {
        if (y % 3 === 0) {
          const lamp = new Mesh(new SphereGeometry(0.07, 10, 8), lampMaterial);
          lamp.position.set(x, 1.05, y);
          this.room.add(lamp);
        } else if (y % 3 === 1) {
          const picture = new Mesh(new PlaneGeometry(0.5, 0.38), pictureMaterial);
          picture.position.set(x, 1.05, y);
          picture.rotation.y = turn;
          this.room.add(picture);
        }
      }
    }
  }

  private makeTableParts() {
    return {
      cloth: new Mesh(new CylinderGeometry(0.36, 0.38, 0.22, 20), new MeshLambertMaterial({ color: '#c8303c' })),
      top: new Mesh(new CylinderGeometry(0.34, 0.34, 0.02, 20), new MeshLambertMaterial({ color: '#fff8ee' })),
      leg: new Mesh(new CylinderGeometry(0.04, 0.06, 0.25, 8), new MeshLambertMaterial({ color: '#4a2a1a' })),
      wood: new MeshLambertMaterial({ color: '#5a3420' }),
    };
  }

  /** A round table with a red tablecloth and a chair on each side. */
  private makeTable(x: number, y: number, parts: ReturnType<Renderer3D['makeTableParts']>): Group {
    const table = new Group();
    table.position.set(x, 0, y);
    const cloth = parts.cloth.clone();
    cloth.position.y = 0.34;
    const top = parts.top.clone();
    top.position.y = 0.46;
    const leg = parts.leg.clone();
    leg.position.y = 0.12;
    table.add(cloth, top, leg);
    for (const side of [-1, 1]) {
      const chair = new Group();
      chair.position.z = side * 0.42;
      const seat = new Mesh(new BoxGeometry(0.28, 0.04, 0.2), parts.wood);
      seat.position.y = 0.28;
      const back = new Mesh(new BoxGeometry(0.28, 0.32, 0.03), parts.wood);
      back.position.set(0, 0.44, side * 0.1);
      chair.add(seat, back);
      for (const [lx, lz] of [[-0.11, -0.08], [0.11, -0.08], [-0.11, 0.08], [0.11, 0.08]]) {
        const chairLeg = new Mesh(new BoxGeometry(0.03, 0.28, 0.03), parts.wood);
        chairLeg.position.set(lx, 0.14, lz);
        chair.add(chairLeg);
      }
      table.add(chair);
    }
    {
      // Tables without food just have a little candle.
      const candle = new Mesh(new CylinderGeometry(0.025, 0.025, 0.1, 8), new MeshLambertMaterial({ color: '#f2e6c9' }));
      candle.position.y = 0.52;
      const flame = new Mesh(new SphereGeometry(0.025, 8, 6), new MeshBasicMaterial({ color: '#ffb43c' }));
      flame.position.y = 0.6;
      this.flames.push(flame);
      const light = new Group();
      light.add(candle, flame);
      this.candles.set(`${x},${y}`, light);
      table.add(light);
    }
    return table;
  }

  private paintKitchenTop(kind: number): CanvasTexture {
    return paint(64, 64, (ctx) => {
      ctx.fillStyle = '#d9dee2';
      ctx.fillRect(0, 0, 64, 64);
      ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
      ctx.fillRect(4, 4, 56, 3);
      switch (kind) {
        case 1:
          // Stove with a glowing burner and a pot.
          ctx.fillStyle = '#2a2a2a';
          ctx.fillRect(8, 8, 48, 48);
          ctx.fillStyle = '#ff5a28';
          ctx.beginPath();
          ctx.arc(32, 32, 17, 0, Math.PI * 2);
          ctx.fill();
          ctx.fillStyle = '#9aa5ad';
          ctx.beginPath();
          ctx.arc(32, 32, 12, 0, Math.PI * 2);
          ctx.fill();
          break;
        case 2:
          // Stack of clean plates.
          ctx.fillStyle = '#ffffff';
          ctx.beginPath();
          ctx.arc(32, 32, 18, 0, Math.PI * 2);
          ctx.fill();
          ctx.strokeStyle = '#c8d0d6';
          ctx.lineWidth = 2;
          ctx.beginPath();
          ctx.arc(32, 32, 12, 0, Math.PI * 2);
          ctx.stroke();
          break;
        case 3:
          // Frying pan with an egg.
          ctx.fillStyle = '#333333';
          ctx.fillRect(36, 30, 24, 6);
          ctx.beginPath();
          ctx.arc(26, 32, 16, 0, Math.PI * 2);
          ctx.fill();
          ctx.fillStyle = '#ffffff';
          ctx.beginPath();
          ctx.arc(26, 32, 8, 0, Math.PI * 2);
          ctx.fill();
          ctx.fillStyle = '#f4d35e';
          ctx.beginPath();
          ctx.arc(26, 32, 4, 0, Math.PI * 2);
          ctx.fill();
          break;
        default:
          // Cutting board with a knife.
          ctx.fillStyle = '#c9925a';
          ctx.fillRect(14, 18, 36, 28);
          ctx.fillStyle = '#e8eef2';
          ctx.fillRect(20, 30, 24, 4);
      }
    });
  }

  private paintDoor(open: boolean): CanvasTexture {
    return paint(64, 84, (ctx) => {
      ctx.fillStyle = '#4a2a1a';
      ctx.fillRect(0, 0, 64, 84);
      ctx.fillStyle = open ? '#3ad17a' : '#8a5a34';
      ctx.fillRect(4, 4, 27, 80);
      ctx.fillRect(33, 4, 27, 80);
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      if (open) {
        ctx.fillStyle = '#ffffff';
        ctx.font = 'bold 16px system-ui, sans-serif';
        ctx.fillText('EXIT', 32, 30);
      } else {
        ctx.font = `20px ${EMOJI_FONT}`;
        ctx.fillText('🔒', 32, 36);
      }
    });
  }

  // ---------- things that change while you play ----------

  private syncFood(view: GameView): void {
    for (const [key, candle] of this.candles) candle.visible = !view.food.has(key);
    for (const [key, sprite] of this.plates) {
      if (!view.food.has(key)) {
        this.scene.remove(sprite);
        this.plates.delete(key);
      }
    }
    for (const [key, dish] of view.food) {
      let sprite = this.plates.get(key);
      if (!sprite) {
        sprite = new Sprite(new SpriteMaterial({ map: this.paintDish(dish) }));
        sprite.scale.setScalar(0.36);
        const [x, y] = key.split(',').map(Number);
        sprite.position.set(x, 0.62, y);
        this.scene.add(sprite);
        this.plates.set(key, sprite);
      }
      // Food bobs a little so you can spot it.
      sprite.position.y = 0.62 + Math.sin(view.clock * 4 + sprite.position.x) * 0.02;
    }
  }

  private dishTextures = new Map<string, CanvasTexture>();

  private paintDish(dish: string): CanvasTexture {
    let texture = this.dishTextures.get(dish);
    if (!texture) {
      texture = paint(64, 64, (ctx) => {
        ctx.font = `52px ${EMOJI_FONT}`;
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillText(dish, 32, 36);
      });
      this.dishTextures.set(dish, texture);
    }
    return texture;
  }

  private syncPeople(view: GameView): void {
    while (this.waiters.length < view.waiters.length) this.addPerson(this.waiters, 'waiter');
    while (this.dashers.length < view.dashers.length) this.addPerson(this.dashers, 'dasher');
    this.waiters.forEach((man, i) => {
      const w = view.waiters[i];
      man.group.visible = !!w;
      if (w) man.place(view, w);
    });
    this.dashers.forEach((man, i) => {
      const d = view.dashers[i];
      // Caught dashers are off washing dishes.
      man.group.visible = !!d && !d.caught;
      if (d) man.place(view, d);
    });
  }

  private addPerson(list: Stickman[], kind: 'waiter' | 'dasher'): void {
    const man = new Stickman(kind);
    list.push(man);
    this.scene.add(man.group);
  }

  /** The waiters' yellow light (red when chasing) on the floor. */
  private syncLight(view: GameView): void {
    let used = 0;
    for (const w of view.waiters) {
      const material: Material = w.mode === 'chase' ? this.chaseMaterial : this.seenMaterial;
      for (const t of view.visionOf(w)) {
        let tile = this.lightTiles[used];
        if (!tile) {
          tile = new Mesh(this.lightGeometry, material);
          tile.rotation.x = -Math.PI / 2;
          tile.renderOrder = 1;
          this.lightTiles.push(tile);
          this.scene.add(tile);
        }
        tile.material = material;
        tile.position.set(t.x, 0.01 + used * 0.0001, t.y);
        tile.visible = true;
        used++;
      }
    }
    for (let i = used; i < this.lightTiles.length; i++) this.lightTiles[i].visible = false;
  }
}