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/game.ts
/**
 * Dine and Dash: sneak around a diner, eat every plate of food, then run out the door
 * without a waiter catching you. The restaurant is a grid of tiles; you see it
 * through your own eyes (render3d.ts), with the belly meter drawn on top here.
 */

export type Dir = 'up' | 'down' | 'left' | 'right';
export type Phase = 'playing' | 'caught' | 'escaped';
/** Who you play: the sneaky dine-and-dasher, or the waiter trying to catch them. */
export type Role = 'dasher' | 'waiter';

const STEP: Record<Dir, [number, number]> = {
  up: [0, -1],
  down: [0, 1],
  left: [-1, 0],
  right: [1, 0],
};

/**
 * The diner. # wall, K kitchen counter, T table, F table with food,
 * D the door out, S where you start (in your seat).
 * Walls and kitchen stay put; a fresh room is made for every game (see makeRoom).
 */
export const COLS = 15;
export const ROWS = 11;
/** How many tables a room gets, and how many of them have food when you're the dasher. */
const TABLES = 12;
const DASHER_MODE_FOOD = 5;

/** A new room: tables in random spots (never touching, so you can always walk round them), a random door and start corner. */
function makeRoom(): string[] {
  for (;;) {
    const grid: string[][] = Array.from({ length: ROWS }, (_, y) =>
      Array.from({ length: COLS }, (_, x) => {
        if (x === 0 || y === 0 || x === COLS - 1 || y === ROWS - 1) return '#';
        if (y === 1 && (x <= 4 || x >= COLS - 5)) return 'K';
        return '.';
      }),
    );
    grid[ROWS - 1][3 + Math.floor(Math.random() * (COLS - 6))] = 'D';
    grid[ROWS - 2][Math.random() < 0.5 ? 1 : COLS - 2] = 'S';
    // Tables go in the middle rows, leaving a clear walkway by the kitchen and along the bottom.
    const spots: Point[] = [];
    for (let y = 3; y <= ROWS - 3; y++) for (let x = 2; x <= COLS - 3; x++) spots.push({ x, y });
    spots.sort(() => Math.random() - 0.5);
    const tables: Point[] = [];
    for (const spot of spots) {
      if (tables.length === TABLES) break;
      if (tables.some((t) => Math.abs(t.x - spot.x) <= 1 && Math.abs(t.y - spot.y) <= 1)) continue;
      tables.push(spot);
    }
    if (tables.length < TABLES) continue;
    tables.forEach((t, i) => (grid[t.y][t.x] = i < DASHER_MODE_FOOD ? 'F' : 'T'));
    return grid.map((row) => row.join(''));
  }
}

/** The room being played right now. */
let MAP = makeRoom();
/** Room at the top of the picture for the belly meter and timer. */
export const HUD_H = 36;
/** The smallest the picture gets (it's scaled to fit, then stretched to fill the screen). */
export const VIEW_W = 480;
export const VIEW_H = 352 + HUD_H;

const FOODS = ['🍔', '🍕', '🍰', '🌭', '🍝', '🍩', '🌮'];

/** Seconds for one step. Waiters are slower than you, until they spot you. */
/** You walk freely: tiles per second forward, backward and sideways, and how fast the keys turn you. */
const WALK_SPEED = 5.5;
const BACK_SPEED = 3.5;
const TURN_SPEED = 3;
/** How wide you are (you bump into walls and tables at this distance). */
const RADIUS = 0.25;
/** How close to a table you need to be to grab its food. */
const REACH = 0.1;
const WAITER_STEP = 0.42;
const CHASE_STEP = 0.2;
/** How far a waiter can see in front of them. */
const SIGHT = 5;
/** How long a waiter looks around ("?") after losing you, before going back to work. */
const SEARCH_TIME = 1.5;
/** How long a waiter has to see you before they notice and chase (when you're the dasher). */
const WAITER_REACT_TIME = 0.6;

/** When you're the waiter: the dashers' step times, how close they notice you, and how long a plate takes them to eat. */
const DASHER_STEP = 0.2;
const DASHER_FLEE_STEP = 0.17;
const DASHER_SIGHT = 5;
const DASHER_EAT_TIME = 1.4;
/** How long a dasher has to see you before they notice and run. */
const DASHER_REACT_TIME = 0.35;
/** How many plates of food are out when you're the waiter. */
const WAITER_MODE_FOOD = 10;
/** How many plates each dasher eats before heading for the door. */
const DASHER_BELLY = 5;
/** Where you start as the waiter (the kitchen), and where the two dashers sit. */
const KITCHEN: Point = { x: 7, y: 1 };
const DASHER_SEATS: Point[] = [{ x: 1, y: 9 }, { x: 13, y: 9 }];

export interface Point {
  x: number;
  y: number;
}

export interface Mover {
  x: number;
  y: number;
  /** Where they're stepping from, and how far through the step they are (0 to 1). */
  fromX: number;
  fromY: number;
  t: number;
  facing: Dir;
}

export interface Waiter extends Mover {
  route: Point[];
  next: number;
  mode: 'patrol' | 'chase' | 'search';
  lastSeen: Point | null;
  searchTimer: number;
  wait: number;
  /** How long they've been looking at you without noticing yet. */
  spotted: number;
}

export interface Dasher extends Mover {
  ate: number;
  /** Seconds left munching the plate in front of them. */
  eating: number;
  scared: boolean;
  /** How long they've been looking at you without noticing yet. */
  spotted: number;
  caught: boolean;
}

interface Pop {
  text: string;
  x: number;
  y: number;
  age: number;
}

export function tileAt(x: number, y: number): string {
  return MAP[y]?.[x] ?? '#';
}

function findTiles(kind: string): Point[] {
  const found: Point[] = [];
  MAP.forEach((row, y) => [...row].forEach((c, x) => c === kind && found.push({ x, y })));
  return found;
}

function makeMover(x: number, y: number, facing: Dir): Mover {
  return { x, y, fromX: x, fromY: y, t: 1, facing };
}

/** Which way you look for each facing, as a turn angle. Facing up is 0. */
const ANGLE: Record<Dir, number> = { up: 0, left: Math.PI / 2, down: Math.PI, right: -Math.PI / 2 };

/** How far a point is from a tile's square (0 if it's inside). */
function gapToTile(x: number, y: number, tx: number, ty: number): number {
  const dx = Math.max(Math.abs(x - tx) - 0.5, 0);
  const dy = Math.max(Math.abs(y - ty) - 0.5, 0);
  return Math.hypot(dx, dy);
}

/** What the controls are asking for right now: each from -1 to 1. */
export interface MoveInput {
  /** Forward (1) or backward (-1). */
  forward: number;
  /** Turn right (1) or left (-1). */
  turn: number;
  /** Step right (1) or left (-1). */
  strafe: number;
}

/** Everything the 3D picture needs to know about one moment of the game. */
export interface GameView {
  role: Role;
  /** This game's room (a new one each game). */
  map: readonly string[];
  player: Mover;
  /** Which way you're looking (0 is up the map, bigger turns left). */
  angle: number;
  /** Goes up as you walk, for swinging arms and a bouncy view. */
  stride: number;
  waiters: Waiter[];
  dashers: Dasher[];
  food: Map<string, string>;
  bellyFull: boolean;
  /** Whether the front door shows as open (green EXIT). */
  doorOpen: boolean;
  clock: number;
  /** Where someone is right now, partway through a step. */
  posOf: (m: Mover) => Point;
  /** Floor tiles lit up by each waiter's yellow light. */
  visionOf: (w: Waiter) => Point[];
}

/** Turns a point in the diner into a spot on the picture, or null if it's behind you. */
export type Project = (x: number, height: number, y: number) => { x: number; y: number } | null;

function dirTo(from: Point, to: Point): Dir {
  if (to.x > from.x) return 'right';
  if (to.x < from.x) return 'left';
  return to.y > from.y ? 'down' : 'up';
}

export class Game {
  phase: Phase = 'playing';
  time = 0;
  paused = true;
  /** What the controls are pressing right now. */
  input: MoveInput = { forward: 0, turn: 0, strafe: 0 };
  /** The picture's size right now: it grows wider or taller to fill the screen. */
  viewW = VIEW_W;
  viewH = VIEW_H;

  role: Role = 'dasher';

  private player = makeMover(0, 0, 'up');
  private angle = 0;
  private stride = 0;
  private moving = false;
  private waiters: Waiter[] = [];
  private dashers: Dasher[] = [];
  private food = new Map<string, string>();
  private eaten = 0;
  private total = 0;
  private pops: Pop[] = [];
  private notice = '';
  private noticeTimer = 0;
  private clock = 0;

  onEnd: (phase: Phase, seconds: number) => void = () => {};

  constructor(private ctx: CanvasRenderingContext2D) {
    this.reset();
  }

  get bellyFull(): boolean {
    return this.eaten >= this.total;
  }

  reset(role: Role = this.role): void {
    this.role = role;
    MAP = makeRoom();
    const start = role === 'waiter' ? KITCHEN : findTiles('S')[0];
    this.player = makeMover(start.x, start.y, role === 'waiter' ? 'down' : 'up');
    this.food.clear();
    // As the waiter there's more food out: 10 plates for the two dashers to go after.
    const plates = role === 'waiter' ? [...findTiles('F'), ...findTiles('T')].slice(0, WAITER_MODE_FOOD) : findTiles('F');
    plates.forEach((p, i) => this.food.set(`${p.x},${p.y}`, FOODS[i % FOODS.length]));
    this.total = this.food.size;
    this.eaten = 0;
    this.time = 0;
    this.pops = [];
    this.notice = '';
    this.angle = ANGLE[this.player.facing];
    this.stride = 0;
    this.moving = false;
    this.input = { forward: 0, turn: 0, strafe: 0 };
    this.phase = 'playing';
    if (role === 'waiter') {
      // You're the only waiter: no helpers.
      this.waiters = [];
      this.dashers = DASHER_SEATS.map((seat) => ({
        ...makeMover(seat.x, seat.y, 'up'),
        ate: 0,
        eating: 0,
        scared: false,
        spotted: 0,
        caught: false,
      }));
      return;
    }
    this.dashers = [];
    // Two waiters walking laps around the tables.
    this.waiters = [
      this.makeWaiter(this.randomRoute(start)),
      this.makeWaiter(this.randomRoute(start)),
    ];
  }

  /** Turn your view (looking around with the mouse). Positive turns left. */
  turnBy(amount: number): void {
    this.angle += amount;
  }

  showNotice(text: string): void {
    this.notice = text;
    this.noticeTimer = 2;
  }

  private makeWaiter(route: Point[]): Waiter {
    const start = route[route.length - 1];
    return { ...makeMover(start.x, start.y, 'left'), route, next: 0, mode: 'patrol', lastSeen: null, searchTimer: 0, wait: 0, spotted: 0 };
  }

  /** A random walking lap for a waiter, starting well away from you so you get a fair start. */
  private randomRoute(awayFrom: Point): Point[] {
    const floor = findTiles('.');
    const pick = () => floor[Math.floor(Math.random() * floor.length)];
    const route = [pick(), pick(), pick()];
    let start = pick();
    while (Math.abs(start.x - awayFrom.x) + Math.abs(start.y - awayFrom.y) < 8) start = pick();
    return [...route, start];
  }

  private walkable(x: number, y: number, doorOpen = this.bellyFull): boolean {
    const c = tileAt(x, y);
    return c === '.' || c === 'S' || (c === 'D' && doorOpen);
  }

  /** A dasher heads for the door once they've eaten their fill (or there's nothing left to eat). */
  private readyToLeave(d: Dasher): boolean {
    return d.ate >= DASHER_BELLY || this.food.size === 0;
  }

  /** Whether the door shows as open: your belly's full, or (as the waiter) a dasher is heading out. */
  private get doorOpen(): boolean {
    if (this.role === 'waiter') return this.dashers.some((d) => !d.caught && this.readyToLeave(d));
    return this.bellyFull;
  }

  update(dt: number): void {
    this.clock += dt;
    this.noticeTimer -= dt;
    this.pops = this.pops.filter((p) => (p.age += dt) < 1);
    if (this.paused || this.phase !== 'playing') return;
    this.time += dt;
    this.updatePlayer(dt);
    if (this.role === 'waiter') {
      for (const dasher of this.dashers) this.updateDasher(dasher, dt);
      this.checkCaughtDashers();
      return;
    }
    for (const waiter of this.waiters) this.updateWaiter(waiter, dt);
    this.checkCaught();
  }

  private updatePlayer(dt: number): void {
    const p = this.player;
    const { forward, turn, strafe } = this.input;
    this.angle -= turn * TURN_SPEED * dt;
    // Walk the way you're looking, and step sideways.
    const ahead = { x: -Math.sin(this.angle), y: -Math.cos(this.angle) };
    const right = { x: Math.cos(this.angle), y: -Math.sin(this.angle) };
    const speed = forward >= 0 ? WALK_SPEED : BACK_SPEED;
    let dx = (ahead.x * forward + right.x * strafe) * speed * dt;
    let dy = (ahead.y * forward + right.y * strafe) * speed * dt;
    const length = Math.hypot(dx, dy);
    const most = WALK_SPEED * dt;
    if (length > most) {
      dx *= most / length;
      dy *= most / length;
    }
    // Slide along walls and tables: try across and up/down separately.
    const startX = p.x;
    const startY = p.y;
    if (this.fits(p.x + dx, p.y)) p.x += dx;
    if (this.fits(p.x, p.y + dy)) p.y += dy;
    const moved = Math.hypot(p.x - startX, p.y - startY);
    this.stride += moved;
    this.moving = moved > 0;
    p.fromX = p.x;
    p.fromY = p.y;
    p.t = 1;
    // The closest of the four ways, for the waiters' eyes and your arms.
    const quarter = Math.round(this.angle / (Math.PI / 2));
    p.facing = (['up', 'left', 'down', 'right'] as Dir[])[((quarter % 4) + 4) % 4];

    if (this.role === 'waiter') return;
    if (tileAt(Math.round(p.x), Math.round(p.y)) === 'D') {
      this.phase = 'escaped';
      this.onEnd('escaped', this.time);
      return;
    }
    // Walk up to a table with food to gobble it up.
    for (const key of [...this.food.keys()]) {
      const [tx, ty] = key.split(',').map(Number);
      if (gapToTile(p.x, p.y, tx, ty) > RADIUS + REACH) continue;
      this.food.delete(key);
      this.eaten++;
      this.pops.push({ text: 'NOM!', x: tx, y: ty, age: 0 });
      if (this.bellyFull) this.showNotice('Belly full! Run for the door!');
    }
    if (!this.bellyFull && this.noticeTimer <= 0) {
      const door = findTiles('D')[0];
      if (gapToTile(p.x, p.y, door.x, door.y) <= RADIUS + REACH) {
        this.showNotice(`Still hungry! Eat ${this.total - this.eaten} more first.`);
      }
    }
  }

  /** Whether you'd fit at a spot without poking into a wall, counter or table. */
  private fits(x: number, y: number): boolean {
    for (let ty = Math.floor(y - RADIUS); ty <= Math.ceil(y + RADIUS); ty++) {
      for (let tx = Math.floor(x - RADIUS); tx <= Math.ceil(x + RADIUS); tx++) {
        const blocked = !this.walkable(tx, ty) || (this.role === 'waiter' && tileAt(tx, ty) === 'D');
        if (blocked && gapToTile(x, y, tx, ty) < RADIUS) return false;
      }
    }
    return true;
  }

  /** The floor tile you're standing on (the waiters and dashers think in tiles). */
  private playerTile(): Mover {
    const p = this.player;
    return makeMover(Math.round(p.x), Math.round(p.y), p.facing);
  }

  private moveTo(m: Mover, x: number, y: number): void {
    m.facing = dirTo(m, { x, y });
    m.fromX = m.x;
    m.fromY = m.y;
    m.x = x;
    m.y = y;
    m.t = 0;
  }

  /** Everyone who catches: you when you're the waiter, otherwise the waiters. */
  private catchers(): Mover[] {
    return this.role === 'waiter' ? [this.playerTile()] : this.waiters;
  }

  /** A dasher (when you're the waiter): eat the food, then run for the door. Run away if the waiter comes close. */
  private updateDasher(d: Dasher, dt: number): void {
    if (d.caught) return;
    const inSight = this.catchers().some(
      (w) => Math.abs(w.x - d.x) + Math.abs(w.y - d.y) <= DASHER_SIGHT && this.clearLine(d, w),
    );
    // They take a moment to notice you before they run.
    d.spotted = inSight ? d.spotted + dt : 0;
    const scared = d.spotted >= DASHER_REACT_TIME;
    d.t = Math.min(1, d.t + dt / (scared ? DASHER_FLEE_STEP : DASHER_STEP));
    if (d.t < 1) return;

    if (tileAt(d.x, d.y) === 'D') {
      this.phase = 'escaped';
      this.pops.push({ text: 'BYE!', x: d.x, y: d.y, age: 0 });
      this.onEnd('escaped', this.time);
      return;
    }

    if (scared) {
      if (!d.scared) this.pops.push({ text: '!', x: d.x, y: d.y, age: 0 });
      d.scared = true;
      d.eating = 0;
      const next = this.fleeStep(d, this.readyToLeave(d));
      if (next) this.moveTo(d, next.x, next.y);
      return;
    }
    d.scared = false;

    const leaving = this.readyToLeave(d);
    // Next to a plate (and still hungry): munch it for a moment, then it's gone.
    const plate = leaving ? null : this.foodNextTo(d);
    if (plate) {
      if (d.eating <= 0) d.eating = DASHER_EAT_TIME;
      d.facing = dirTo(d, plate);
      d.eating -= dt;
      if (d.eating > 0) return;
      this.food.delete(`${plate.x},${plate.y}`);
      this.eaten++;
      d.ate++;
      this.pops.push({ text: 'NOM!', x: plate.x, y: plate.y, age: 0 });
      if (this.readyToLeave(d)) this.showNotice("A dasher is full! They're running for the door!");
      return;
    }

    const next = leaving
      ? this.pathToward(d, (p) => tileAt(p.x, p.y) === 'D', true)
      : this.pathToward(d, (p) => this.foodNextTo(p) !== null, false);
    if (next) this.moveTo(d, next.x, next.y);
  }

  private foodNextTo(p: Point): Point | null {
    for (const [dx, dy] of Object.values(STEP)) {
      if (this.food.has(`${p.x + dx},${p.y + dy}`)) return { x: p.x + dx, y: p.y + dy };
    }
    return null;
  }

  /** Run away: step to the tile that's the longest walk from the waiters. Head for the door if it's open. */
  private fleeStep(d: Dasher, doorOpen: boolean): Point | null {
    const far = this.walkDistances(this.catchers());
    let best: Point | null = null;
    let bestScore = far.get(`${d.x},${d.y}`) ?? 0;
    for (const [dx, dy] of Object.values(STEP)) {
      const n = { x: d.x + dx, y: d.y + dy };
      if (!this.walkable(n.x, n.y, doorOpen)) continue;
      if (tileAt(n.x, n.y) === 'D') return n;
      const score = (far.get(`${n.x},${n.y}`) ?? 0) + Math.random() * 0.5;
      if (score > bestScore) {
        best = n;
        bestScore = score;
      }
    }
    return best;
  }

  /** How many steps it takes to walk to every tile from the nearest of some starting tiles. */
  private walkDistances(from: Point[]): Map<string, number> {
    const dist = new Map<string, number>(from.map((p) => [`${p.x},${p.y}`, 0]));
    const queue: Point[] = [...from];
    while (queue.length > 0) {
      const at = queue.shift()!;
      const d = dist.get(`${at.x},${at.y}`)!;
      for (const [dx, dy] of Object.values(STEP)) {
        const n = { x: at.x + dx, y: at.y + dy };
        const key = `${n.x},${n.y}`;
        if (dist.has(key) || tileAt(n.x, n.y) === 'D' || !this.walkable(n.x, n.y)) continue;
        dist.set(key, d + 1);
        queue.push(n);
      }
    }
    return dist;
  }

  private checkCaughtDashers(): void {
    const waiters = [this.player, ...this.waiters].map((w) => this.drawPos(w));
    for (const d of this.dashers) {
      if (d.caught) continue;
      const q = this.drawPos(d);
      if (waiters.some((p) => Math.hypot(p.x - q.x, p.y - q.y) < 0.65)) {
        d.caught = true;
        this.pops.push({ text: 'GOTCHA!', x: d.x, y: d.y, age: 0 });
        const left = this.dashers.filter((other) => !other.caught).length;
        if (left > 0) this.showNotice(`Gotcha! ${left} more to catch!`);
        else {
          this.phase = 'caught';
          this.onEnd('caught', this.time);
        }
      }
    }
  }

  private updateWaiter(w: Waiter, dt: number): void {
    // Waiters chase you when you're the dasher, or the dashers when you're the waiter.
    const prey = this.role === 'dasher' ? [this.playerTile()] : this.dashers.filter((d) => !d.caught);
    const p = prey.find((target) => this.canSee(w, target));
    // When you're the dasher, they take a moment to notice you (once chasing, they keep up).
    w.spotted = p ? w.spotted + dt : 0;
    const react = this.role === 'dasher' ? WAITER_REACT_TIME : 0;
    if (p && (w.mode === 'chase' || w.spotted >= react)) {
      if (w.mode !== 'chase') this.pops.push({ text: '!', x: w.x, y: w.y, age: 0 });
      w.mode = 'chase';
      w.lastSeen = { x: p.x, y: p.y };
    }

    const step = w.mode === 'chase' ? CHASE_STEP : WAITER_STEP;
    w.t = Math.min(1, w.t + dt / step);
    if (w.t < 1) return;

    let target: Point;
    if (w.mode === 'chase' && w.lastSeen) {
      if (w.x === w.lastSeen.x && w.y === w.lastSeen.y) {
        // Lost you. Look around for a moment.
        w.mode = 'search';
        w.searchTimer = SEARCH_TIME;
        w.lastSeen = null;
        this.pops.push({ text: '?', x: w.x, y: w.y, age: 0 });
        return;
      }
      target = w.lastSeen;
    } else if (w.mode === 'search') {
      w.searchTimer -= dt;
      // Turn around while looking.
      const turns: Dir[] = ['up', 'right', 'down', 'left'];
      w.facing = turns[Math.floor(this.clock * 1.5) % 4];
      if (w.searchTimer > 0) return;
      w.mode = 'patrol';
      target = w.route[w.next];
    } else {
      if (w.x === w.route[w.next].x && w.y === w.route[w.next].y) {
        w.next = (w.next + 1) % w.route.length;
      }
      target = w.route[w.next];
    }

    const next = this.pathStep(w, target);
    if (!next) return;
    w.facing = dirTo(w, next);
    w.fromX = w.x;
    w.fromY = w.y;
    w.x = next.x;
    w.y = next.y;
    w.t = 0;
  }

  /** The first tile on the shortest walk from a mover to a target. */
  private pathStep(from: Point, to: Point): Point | null {
    return this.pathToward(from, (p) => p.x === to.x && p.y === to.y, false);
  }

  /** The first tile on the shortest walk to the nearest tile that `isGoal` likes. */
  private pathToward(from: Point, isGoal: (p: Point) => boolean, door: boolean): Point | null {
    const seen = new Map<string, Point | null>([[`${from.x},${from.y}`, null]]);
    const queue: Point[] = [{ x: from.x, y: from.y }];
    while (queue.length > 0) {
      const at = queue.shift()!;
      if (isGoal(at)) {
        let step = at;
        let back = seen.get(`${step.x},${step.y}`);
        while (back && !(back.x === from.x && back.y === from.y)) {
          step = back;
          back = seen.get(`${step.x},${step.y}`);
        }
        return step.x === from.x && step.y === from.y ? null : step;
      }
      for (const [dx, dy] of Object.values(STEP)) {
        const n = { x: at.x + dx, y: at.y + dy };
        const key = `${n.x},${n.y}`;
        // Waiters never leave through the front door.
        if (seen.has(key) || (!door && tileAt(n.x, n.y) === 'D') || !this.walkable(n.x, n.y, door)) continue;
        seen.set(key, at);
        queue.push(n);
      }
    }
    return null;
  }

  /** Tiles a waiter can see: a cone in front of them, blocked by tables and walls. */
  private visionTiles(w: Waiter): Point[] {
    const [fx, fy] = STEP[w.facing];
    const tiles: Point[] = [];
    for (let d = 1; d <= SIGHT; d++) {
      const spread = Math.floor(d / 2);
      for (let side = -spread; side <= spread; side++) {
        const x = w.x + fx * d + fy * side;
        const y = w.y + fy * d + fx * side;
        if (this.walkable(x, y) && this.clearLine(w, { x, y })) tiles.push({ x, y });
      }
    }
    return tiles;
  }

  private clearLine(a: Point, b: Point): boolean {
    const steps = Math.max(Math.abs(b.x - a.x), Math.abs(b.y - a.y));
    for (let i = 1; i < steps; i++) {
      const x = Math.round(a.x + ((b.x - a.x) * i) / steps);
      const y = Math.round(a.y + ((b.y - a.y) * i) / steps);
      if (!this.walkable(x, y)) return false;
    }
    return true;
  }

  private canSee(w: Waiter, p: Point): boolean {
    // Right next to a waiter counts as seen, even behind their back.
    if (Math.abs(w.x - p.x) + Math.abs(w.y - p.y) <= 1) return true;
    return this.visionTiles(w).some((t) => t.x === p.x && t.y === p.y);
  }

  private checkCaught(): void {
    const p = this.drawPos(this.player);
    for (const w of this.waiters) {
      const q = this.drawPos(w);
      if (Math.hypot(p.x - q.x, p.y - q.y) < 0.65) {
        this.phase = 'caught';
        this.pops.push({ text: 'CAUGHT!', x: this.player.x, y: this.player.y, age: 0 });
        this.onEnd('caught', this.time);
        return;
      }
    }
  }

  private drawPos(m: Mover): Point {
    const t = m.t;
    return { x: m.fromX + (m.x - m.fromX) * t, y: m.fromY + (m.y - m.fromY) * t };
  }

  view(): GameView {
    return {
      role: this.role,
      map: MAP,
      player: this.player,
      angle: this.angle,
      stride: this.stride,
      waiters: this.waiters,
      dashers: this.dashers,
      food: this.food,
      bellyFull: this.bellyFull,
      doorOpen: this.doorOpen,
      clock: this.clock,
      posOf: (m) => this.drawPos(m),
      visionOf: (w) => this.visionTiles(w),
    };
  }

  // ---------- drawing on top of the 3D picture ----------

  draw(project: Project): void {
    const ctx = this.ctx;
    ctx.clearRect(0, 0, this.viewW, this.viewH);
    this.drawChased();
    this.drawPops(project);
    this.drawHands();
    this.drawHud();
  }

  /** The edges of the screen glow red while a waiter is chasing you. */
  private drawChased(): void {
    if (this.role !== 'dasher' || !this.waiters.some((w) => w.mode === 'chase')) return;
    const ctx = this.ctx;
    const glow = ctx.createRadialGradient(this.viewW / 2, this.viewH / 2, this.viewH * 0.35, this.viewW / 2, this.viewH / 2, this.viewW * 0.6);
    glow.addColorStop(0, 'rgba(255, 40, 40, 0)');
    glow.addColorStop(1, `rgba(255, 40, 40, ${0.35 + Math.sin(this.clock * 10) * 0.15})`);
    ctx.fillStyle = glow;
    ctx.fillRect(0, HUD_H, this.viewW, this.viewH - HUD_H);
  }

  /** Your own arms at the bottom of the screen: green with white hoodie sleeves, or black for the waiter. */
  private drawHands(): void {
    const ctx = this.ctx;
    const swing = this.moving ? Math.sin(this.stride * 3) * 10 : Math.sin(this.clock * 2) * 2;
    const waiter = this.role === 'waiter';
    ctx.lineCap = 'round';
    for (const side of [-1, 1]) {
      const bob = side === 1 ? swing : -swing;
      const shoulderX = this.viewW / 2 + side * 185;
      const shoulderY = this.viewH + 40;
      const elbowX = this.viewW / 2 + side * 155;
      const elbowY = this.viewH - 20 + bob;
      const handX = this.viewW / 2 + side * 125;
      const handY = this.viewH - 55 + bob;
      // Sleeve up to the elbow.
      ctx.strokeStyle = waiter ? '#111111' : '#f4f4f4';
      ctx.lineWidth = waiter ? 9 : 24;
      ctx.beginPath();
      ctx.moveTo(shoulderX, shoulderY);
      ctx.lineTo(elbowX, elbowY);
      ctx.stroke();
      if (!waiter) {
        ctx.strokeStyle = '#d0d0d0';
        ctx.lineWidth = 26;
        ctx.beginPath();
        ctx.moveTo(elbowX + (shoulderX - elbowX) * 0.08, elbowY + (shoulderY - elbowY) * 0.08);
        ctx.lineTo(elbowX, elbowY);
        ctx.stroke();
      }
      // Stick forearm and a round hand.
      ctx.strokeStyle = waiter ? '#111111' : '#2fbf4f';
      ctx.lineWidth = 8;
      ctx.beginPath();
      ctx.moveTo(elbowX, elbowY);
      ctx.lineTo(handX, handY);
      ctx.stroke();
      ctx.fillStyle = ctx.strokeStyle;
      ctx.beginPath();
      ctx.arc(handX, handY, 9, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  /** Little words like NOM! and ! that float up over people and tables. */
  private drawPops(project: Project): void {
    const ctx = this.ctx;
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    for (const pop of this.pops) {
      const at = project(pop.x, 1.25 + pop.age * 0.3, pop.y);
      if (!at) continue;
      ctx.globalAlpha = 1 - pop.age;
      ctx.font = 'bold 18px system-ui, sans-serif';
      ctx.lineWidth = 4;
      ctx.strokeStyle = '#2a2430';
      ctx.strokeText(pop.text, at.x, at.y);
      ctx.fillStyle = pop.text === 'NOM!' ? '#ffd640' : '#ff5a8a';
      ctx.fillText(pop.text, at.x, at.y);
    }
    ctx.globalAlpha = 1;
  }

  private drawHud(): void {
    const ctx = this.ctx;
    ctx.fillStyle = '#1c1420';
    ctx.fillRect(0, 0, this.viewW, HUD_H);
    ctx.textBaseline = 'middle';
    ctx.textAlign = 'left';
    ctx.font = 'bold 14px system-ui, sans-serif';
    ctx.fillStyle = '#ff5a8a';
    if (this.role === 'waiter') {
      // Food still on the tables, and how many dashers you've caught.
      ctx.fillText('FOOD', 10, HUD_H / 2);
      for (let i = 0; i < this.total; i++) {
        ctx.fillStyle = i < this.total - this.eaten ? '#ffd640' : '#3b2f45';
        ctx.beginPath();
        ctx.arc(60 + i * 22, HUD_H / 2, 8, 0, Math.PI * 2);
        ctx.fill();
      }
      // Kept right of the middle so the Menu button doesn't cover it.
      ctx.fillStyle = '#ff5a8a';
      ctx.textAlign = 'right';
      ctx.fillText('CAUGHT', this.viewW / 2 + 96, HUD_H / 2);
      this.dashers.forEach((d, i) => {
        ctx.fillStyle = d.caught ? '#2fbf4f' : '#3b2f45';
        ctx.beginPath();
        ctx.arc(this.viewW / 2 + 110 + i * 22, HUD_H / 2, 8, 0, Math.PI * 2);
        ctx.fill();
      });
    } else {
      ctx.fillText('BELLY', 10, HUD_H / 2);
      for (let i = 0; i < this.total; i++) {
        ctx.fillStyle = i < this.eaten ? '#ffd640' : '#3b2f45';
        ctx.beginPath();
        ctx.arc(64 + i * 22, HUD_H / 2, 8, 0, Math.PI * 2);
        ctx.fill();
      }
    }
    ctx.textAlign = 'right';
    ctx.fillStyle = '#4fe3d0';
    ctx.fillText(`${this.time.toFixed(1)} s`, this.viewW - 10, HUD_H / 2);
    if (this.noticeTimer > 0 && this.notice) {
      ctx.textAlign = 'center';
      ctx.font = 'bold 15px system-ui, sans-serif';
      const y = HUD_H + 24;
      ctx.lineWidth = 4;
      ctx.strokeStyle = '#1c1420';
      ctx.strokeText(this.notice, this.viewW / 2, y);
      ctx.fillStyle = '#ffffff';
      ctx.fillText(this.notice, this.viewW / 2, y);
    }
  }
}