Four ghosts, one rule: target-tile AI and forgiving controls in a maze-chase game

How MacMan's four ghosts get distinct personalities from one steering rule and four target tiles, how the Scatter/Chase state machine works, and why turn buffering and cornering belong in the engine.

DSDilawar Singh 10 min read 11 views
Four ghosts, one rule: target-tile AI and forgiving controls in a maze-chase game

MacMan is our free maze-chase arcade game for the browser, built with Phaser 3 and TypeScript. You eat every dot in a maze while four ghosts hunt you, and an energizer turns them blue for a few seconds so you can eat them back. The rules take one sentence. The hard part is making the ghosts feel like opponents rather than a queue. If every ghost runs the shortest path to the player, all four end up in single file behind you and the game is over in a minute. The 1980 arcade original solved this with an elegant trick, and we rebuilt MacMan around it.

This article covers three things. First, how one steering rule plus four different target tiles gives four distinct personalities. Second, the state machine that decides which target each ghost uses. Third, why turn buffering and cornering belong in the rules engine and not in the input layer.

One steering rule for every ghost

A roaming ghost in MacMan never runs a pathfinder. It makes one small decision, and only when it reaches the centre of a tile: of the exits open to it, which next tile is closest in a straight line to its target? It may never turn back the way it came, and ties go to Up, then Left, then Down, then Right.

let options = exits(maze, t.x, t.y).filter((d) => d !== OPPOSITE[g.dir]);
if (!options.length) options = [OPPOSITE[g.dir]];

if (g.mode === GhostMode.Frightened) {
  g.dir = options[Math.floor(random(s) * options.length)];
  return;
}

const target = targetFor(s, g);
let best = options[0];
let bestD = Infinity;
for (const d of DIRS) {               // Up, Left, Down, Right
  if (!options.includes(d)) continue;
  const d2 = (t.x + DX[d] - target.x) ** 2 + (t.y + DY[d] - target.y) ** 2;
  if (d2 < bestD) {
    bestD = d2;
    best = d;
  }
}
g.dir = best;

(condensed from ghosts/ghost.ts)

This greedy rule is deliberately imperfect. A ghost can take the "wrong" branch when a wall sits between it and its target, and the no-reverse rule means it commits to a corridor once it has entered it. Those imperfections are what make ghosts readable. A player can learn where a ghost will turn, and a ghost cannot snap back and forth when the target moves behind it. The rule is also cheap. A decision is four distance comparisons, made a few times per second per ghost, with no search at all.

The rule has one more property that matters. Every input is a tile coordinate, and every choice is either a fixed tie-break or a draw from the game's seeded PRNG. The same seed and the same player inputs therefore always produce the same ghost moves. One of our tests plays 90 seconds with the built-in autopilot twice from the same seed and asserts that the ghosts, the score and every pellet are identical.

Four personalities are four target functions

If every ghost uses the same steering rule, all of the personality lives in the target. MacMan's chaseTarget() is the whole of it:

export function chaseTarget(id: GhostId, s: GameState): Point {
  const pac = tileOf(s.maze, s.pac);
  switch (id) {
    case GhostId.Red:
      return pac;
    case GhostId.Pink:
      return ahead(s, 4);
    case GhostId.Cyan: {
      const pivot = ahead(s, 2);
      const red = tileOf(s.maze, s.ghosts[GhostId.Red]);
      return { x: 2 * pivot.x - red.x, y: 2 * pivot.y - red.y };
    }
    case GhostId.Orange: {
      const me = tileOf(s.maze, s.ghosts[GhostId.Orange]);
      const d2 = (me.x - pac.x) ** 2 + (me.y - pac.y) ** 2;
      return d2 > ORANGE_SHY_RADIUS ** 2 ? pac : scatterTarget(GhostId.Orange, s.maze);
    }
  }
}

(from ghosts/targeting.ts)

The four profiles do the following:

  • Blaze (red), the hunter, aims at the player's tile. On its own it is the "shortest path" ghost, which is exactly why it needs company.
  • Pixie (pink), the ambusher, aims four tiles ahead of the direction the player is facing. When you run down a corridor toward Pixie, it heads for the junction you are about to reach instead of your current tile. Pixie and Blaze approaching from opposite ends of a corridor is the classic pincer.
  • Frost (cyan), the flanker, has the most interesting rule. It takes the point two tiles ahead of the player and reflects Blaze's position through it. When Blaze is directly behind you, Frost aims ahead of you. When Blaze comes from the left, Frost aims to your right. Frost ends up covering the side Blaze leaves open, without any code that knows what "flanking" means.
  • Ember (orange), the wanderer, chases you while it is more than eight tiles away, then gives up and heads for its corner as soon as it gets close. Near you it looks indecisive. From a distance it still adds pressure.

None of the ghosts know about each other except Frost, which reads Blaze's tile. There is no coordinator, no shared plan and no search. The team behaviour comes entirely from the four different targets, and players read it as intelligence.

Targets may lie outside the maze or inside walls. That is fine, because a target only ranks the exits. Pixie's target four tiles ahead often lands in a wall, and the steering rule still picks the open exit closest to it.

The state machine that picks the target

A ghost is not always chasing. Each one carries a mode, and the mode decides where the target comes from:

Mode Target Notes
Scatter A fixed corner outside the maze Each ghost circles "its" corner block
Chase chaseTarget() The four personalities above
Frightened None A random allowed exit from the seeded PRNG
Eaten The house door The eyes follow a BFS distance field

Scatter and Chase alternate on a timer whose schedule depends on the level. On level 1 the ghosts scatter for 7 seconds, chase for 20, and repeat, with a shorter scatter later and chasing forever after the last step. From level 5 the scatters drop to 5 seconds. The pauses in the pressure are part of the design: they give the player a moment to clear a corner of the maze.

Two details make the state machine feel right. First, every switch reverses all roaming ghosts. It is the player's cue that something changed, and it breaks up groups that were about to corner them:

s.modeIndex++;
s.globalMode = s.modeIndex % 2 === 0 ? GhostMode.Scatter : GhostMode.Chase;
for (const g of s.ghosts) {
  if (g.mode !== GhostMode.Scatter && g.mode !== GhostMode.Chase) continue;
  g.mode = s.globalMode;
  reverseGhost(g);
}

(condensed from modes.ts)

Second, the schedule clock stops while ghosts are frightened. Eating an energizer doesn't use up the scatter time you would have had. The fright duration itself comes from a level table: 6 seconds on level 1, then 5, 4, 3, 2 and down to zero on level 17. At zero an energizer only turns the ghosts around. The ghosts flash white for a number of half-periods before fright ends, so the player gets a warning.

Eaten ghosts take the shortest path home

The greedy target rule is wrong for eyes returning to the house. Eyes should never wander, and a greedy rule can loop around a block whose far side is closer in a straight line. Eaten ghosts therefore follow a breadth-first distance field computed once per maze. Every walkable tile stores its distance to the tiles under the house exit, and at each centre the eyes step to the neighbour with the smallest value. The distance strictly decreases at every step, so the eyes cannot loop, and we let them reverse here. Once they reach the exit, a short scripted path takes them through the door into the house, where they revive and leave again.

The house releases ghosts by counting dots

At the start of a level only Blaze is outside. The others wait in the house, and the first waiting ghost counts the dots you eat until its personal limit is reached. On level 1 Pixie leaves at once, Frost after 30 dots and Ember after 60. After you lose a life, a shared counter takes over and releases them at 7, 17 and 32 dots. A no-eating timer also forces the next ghost out if you stop eating for four seconds (three from level 5), so waiting in a corner is never a safe strategy.

Forgiving controls are a rules problem

A maze-chase game lives or dies on turning. Actors move along tile centre lines, so strictly a turn is only possible at the exact moment the player is centred on a junction. At 7.5 to 9.4 tiles per second that moment lasts about one frame. No human hits that window with a keyboard, and nobody hits it with a swipe.

MacMan fixes this in the engine, in two layers.

Turn buffering. A pressed direction is stored on the player with an age and kept for 0.6 seconds. Each tick the engine tries to apply it. Pressing Up a little before a junction therefore works: the turn waits and happens at the first opening.

Cornering. The turn is accepted not only at the exact centre but anywhere within 0.42 tiles before or after it. The off-axis error is then absorbed while MacMan moves in the new direction:

const alongOffset =
  pac.dir === Dir.None ? 0
  : DX[pac.dir] !== 0 ? Math.abs(pac.x - Math.round(pac.x))
  : Math.abs(pac.y - Math.round(pac.y));
if (alongOffset <= CORNER_WINDOW && canStep(maze, t.x, t.y, q)) {
  pac.dir = q;
  pac.queued = Dir.None;
}

// ...and in advance(): pull the other axis back onto the centre line
if (horizontal) a.y = approach(a.y, Math.round(a.y), rem);
else a.x = approach(a.x, Math.round(a.x), rem);

(condensed from movement.ts)

The visible effect is that MacMan cuts corners on a short diagonal, which feels smooth and slightly rewards turning early. Ghosts never corner. They always turn exactly on centres, so a player who turns well gains a little distance on them every corner.

Putting this in the engine and not in the UI has two benefits. Every input device gets the same behaviour, including the gamepad, the joystick, the d-pad and swipes. And it is testable without a browser: our movement tests place MacMan at x = 12.3, queue Up and assert that it turns immediately. At x = 12.5, just outside the window, it must not turn until it gets closer.

The bug the browser tests found

One bug only showed up in the browser. Playwright's keyboard.press() sends key down and key up within a few milliseconds, faster than one 16.7 ms simulation tick. The input layer reported the held direction each tick, and by the next tick nothing was held, so the tap vanished before the turn buffer saw it. Real players do this too on a quick flick. The fix is a one-slot latch: the last pressed direction is kept until the simulation samples it once.

sample(): Dir {
  const held = this.currentDir();
  const out = held !== Dir.None ? held : this.tap;
  this.tap = Dir.None;
  return out;
}

(from input/InputManager.ts)

A fixed step under a variable display

All of this depends on the simulation advancing in exact 1/60-second ticks, whatever the screen does. The browser driver keeps an accumulator: each animation frame adds the real elapsed time and runs as many fixed step() calls as fit, at most six, so a stalled tab can't spiral. Before each tick it snapshots the actors' positions. The renderer then draws every actor between its last two tick positions, using the leftover fraction of a tick. A 144 Hz monitor sees smooth motion, a 60 Hz phone sees exactly one tick per frame, and the ghosts move the same tile-for-tile on both. Jumps of more than a tile, from the side tunnels or a respawn, skip interpolation so nothing slides across the screen.

The same separation keeps the tests fast. The engine has no DOM and no Phaser, so the 46 Vitest tests drive it directly, including a 90-second autopilot game, in about three seconds. The six Playwright tests then check what only a browser can show: rendering, input devices, touch layouts and the high-score flow.

What we took from it

  • Personality can be a function, not a behaviour tree. One shared steering rule plus four small target functions produced ghosts that players describe as "smart". The shared rule keeps the system predictable and the targets make it varied.
  • Constraints are features. The no-reverse rule and the reversal on every mode switch make ghost movement readable, and that readability is what makes the game fair.
  • Put feel in the rules. Turn buffering, cornering and tap latching are gameplay, not input plumbing. Keeping them in the deterministic engine let us test them precisely and give every device the same behaviour.

MacMan is a free download on the DEV3 marketplace with the full TypeScript source, and you can play it in your browser right now. For the architecture, the numbers and screenshots, read the case study.

DS

Written by

Dilawar Singh

CTO, DEV3 Technologies · ClinicPro, Inventra & WorkPro

Dilawar Singh is the CTO of DEV3 Technologies. He leads the engineering of DEV3’s own products — ClinicPro, Inventra and WorkPro — which are built, sold and supported by the DEV3 team from Mohali, India.

Keep reading

All articles