Reversing the Game Loop: Building a Real-Time "Inverted" AI State Machine in TypeScript

Hey everyone, welcome back to another post here on Coding with Alex.

If you've been browsing Hacker News today, you might have spotted a delightfully nostalgic project climbing the front page: "Pac-Man, but you're the ghost." On its surface, it sounds like a fun, casual web game. But as software engineers, our brains immediately start decomposing the mechanics behind the screen. How do you flip a classic, deterministic game engine on its head?

For decades, Pac-Man has been the textbook example of classic finite state machines (FSMs) and pathfinding algorithms like A*. In the original 1980 arcade game, Pac-Man was controlled by a human, while the four ghosts ran on highly predictable, hardcoded algorithmic behaviors (Blinky chases, Pinky ambushes, Inky flanks, and Clyde behaves pseudo-randomly).

But what happens when you invert the control loop? When a human plays the ghost, the machine must now simulate a highly convincing, adaptive, and human-like Pac-Man. If Pac-Man just runs randomly, the game is boring. If he uses a basic shortest-path algorithm to eat dots, he'll run straight into you every time. To make this game fun, you have to build an autonomous agent that dynamically balances survival, greed (eating dots), and tactical evasion in real-time.

Today, we’re going under the hood. We'll explore how to build an inverted game state machine, write an adaptive decision-making algorithm for our AI-controlled Pac-Man in TypeScript, and discuss how to handle real-time state synchronization in web-based games.

The Architectural Challenge: Inverting the Classic Game Loop

In a traditional grid-based game, the game loop updates positions based on discrete ticks. In a standard Pac-Man clone, the player's inputs are evaluated, and then the ghost AI calculates its target tile based on the player's current tile and orientation.

When the player becomes the ghost, we face three primary architectural challenges:

  • Predictive Pathfinding: The AI Pac-Man needs to constantly recalculate paths not just to the nearest food, but away from the player-controlled ghost, accounting for dead ends.
  • State Transitions on the Fly: Pac-Man must switch between "Gathering" (eating dots), "Evading" (running from ghosts), and "Hunting" (chasing ghosts when a Energizer/Power Pellet is consumed) seamlessly.
  • Ticks vs. Delta Time: To make the web game feel smooth, we need to decouple the game logic ticks from the browser's rendering cycles (using requestAnimationFrame).

Let's look at how we can model Pac-Man's cognitive states using a State Machine pattern. This isn't just useful for game development; it's the exact same architectural pattern you use for building complex UI flows, background worker queues, and automated devops pipelines.

Modeling the AI Pac-Man State Machine

To make our automated Pac-Man feel alive, we will define three core states using a TypeScript enum:

export enum PacState {
  GATHERING = 'GATHERING',
  EVADING = 'EVADING',
  HUNTING = 'HUNTING'
}

Here is a structural overview of how our AI transitions between these states based on environmental triggers:

[GATHERING]
Trigger: Ghost gets within N tiles → Transition to [EVADING]
Trigger: Eats Power Pellet → Transition to [HUNTING]

[EVADING]
Trigger: Ghost lost/far away → Transition to [GATHERING]
Trigger: Eats Power Pellet → Transition to [HUNTING]

[HUNTING]
Trigger: Power Pellet timer expires → Transition to [GATHERING]

Implementing the Decision Engine in TypeScript

Let's write a robust, clean TypeScript class that implements this decision engine. We will use a simplified 2D grid representation where 0 is empty space, 1 is a wall, 2 is a regular dot, and 3 is an Energizer pellet.

interface Position {
  x: number;
  y: number;
}

export class AIPacman {
  private position: Position;
  private state: PacState = PacState.GATHERING;
  private speed: number = 2; // Grid units per second
  private huntingTimer: number = 0; // Milliseconds remaining for Energizer

  constructor(startPos: Position) {
    this.position = startPos;
  }

  /**
   * Main update loop called on every game tick
   */
  public update(
    deltaTime: number, 
    ghostPositions: Position[], 
    grid: number[][], 
    isPowerPelletActive: boolean
  ): void {
    // 1. Manage state transition timers
    if (this.state === PacState.HUNTING) {
      this.huntingTimer -= deltaTime;
      if (this.huntingTimer <= 0) {
        this.state = PacState.GATHERING;
      }
    }

    // 2. Evaluate environmental conditions and transition states
    const nearestGhost = this.getNearestGhost(ghostPositions);
    const distanceToGhost = this.getManhattanDistance(this.position, nearestGhost);

    if (isPowerPelletActive && this.state !== PacState.HUNTING) {
      this.state = PacState.HUNTING;
      this.huntingTimer = 6000; // 6 seconds of hunt mode
    } else if (this.state !== PacState.HUNTING) {
      // If a ghost is within 4 tiles, panic and evade!
      if (distanceToGhost < 4) {
        this.state = PacState.EVADING;
      } else {
        this.state = PacState.GATHERING;
      }
    }

    // 3. Execute pathfinding based on current state
    const nextMove = this.calculateNextMove(grid, ghostPositions);
    this.moveTowards(nextMove, deltaTime);
  }

  private getManhattanDistance(p1: Position, p2: Position): number {
    return Math.abs(p1.x - p2.x) + Math.abs(p1.y - p2.y);
  }

  private getNearestGhost(ghosts: Position[]): Position {
    return ghosts.reduce((nearest, current) => {
      const distToCurrent = this.getManhattanDistance(this.position, current);
      const distToNearest = this.getManhattanDistance(this.position, nearest);
      return distToCurrent < distToNearest ? current : nearest;
    });
  }

  /**
   * Decides which adjacent tile to move to next
   */
  private calculateNextMove(grid: number[][], ghosts: Position[]): Position {
    const validMoves = this.getValidAdjacentTiles(this.position, grid);
    
    if (validMoves.length === 0) return this.position; // Trapped!

    const nearestGhost = this.getNearestGhost(ghosts);

    if (this.state === PacState.EVADING) {
      // Pathfind AWAY from the nearest ghost.
      // We sort moves descending by their distance to the threat.
      validMoves.sort((a, b) => {
        return this.getManhattanDistance(b, nearestGhost) - this.getManhattanDistance(a, nearestGhost);
      });
      return validMoves[0];
    }

    if (this.state === PacState.HUNTING) {
      // Pathfind TOWARDS the nearest ghost to eat them.
      validMoves.sort((a, b) => {
        return this.getManhattanDistance(a, nearestGhost) - this.getManhattanDistance(b, nearestGhost);
      });
      return validMoves[0];
    }

    // Default: GATHERING (Find the nearest dot or pellet using BFS)
    return this.findShortestPathToFood(grid, validMoves);
  }

  private getValidAdjacentTiles(pos: Position, grid: number[][]): Position[] {
    const directions = [
      { x: 0, y: -1 }, // Up
      { x: 0, y: 1 },  // Down
      { x: -1, y: 0 }, // Left
      { x: 1, y: 0 }   // Right
    ];

    return directions
      .map(dir => ({ x: pos.x + dir.x, y: pos.y + dir.y }))
      .filter(next => {
        // Ensure within bounds and not hitting a wall (1 = Wall)
        return (
          next.y >= 0 && next.y < grid.length &&
          next.x >= 0 && next.x < grid[0].length &&
          grid[next.y][next.x] !== 1
        );
      });
  }

  private findShortestPathToFood(grid: number[][], options: Position[]): Position {
    // For simplicity in this demo, we'll pick the move that keeps us close to the grid 
    // coordinates containing pellets (2 or 3). A full implementation would use Breadth-First Search (BFS).
    return options[Math.floor(Math.random() * options.length)];
  }

  private moveTowards(target: Position, deltaTime: number): void {
    // Interpolate our position based on speed and deltaTime for smooth rendering
    const step = (this.speed * deltaTime) / 1000;
    
    const dx = target.x - this.position.x;
    const dy = target.y - this.position.y;
    
    if (Math.abs(dx) < step && Math.abs(dy) < step) {
      this.position = target;
    } else {
      this.position.x += Math.sign(dx) * step;
      this.position.y += Math.sign(dy) * step;
    }
  }

  public getPosition(): Position {
    return this.position;
  }
}

Why Manhattan Distance and BFS Matter Here

In grid-based pathfinding, Euclidean distance (straight-line distance using the Pythagorean theorem) is highly inefficient because game characters cannot move diagonally through walls. Instead, we use Manhattan Distance (sum of absolute horizontal and vertical differences).

For finding dots, implementing a simple Breadth-First Search (BFS) queue is ideal because it is guaranteed to find the absolute shortest path on unweighted graphs (our grid). This makes Pac-Man look incredibly smart, darting through tunnels to grab dots while staying clear of the player's hunting grounds.

Rendering and Game Loop Integration

To integrate this state machine with modern web technologies, we can use a standard Canvas rendering loop in a React component or vanilla JavaScript. To keep things clean, we will use vanilla JS coupled with requestAnimationFrame to handle the delta time calculation correctly, preventing the game speed from being tied to the user's monitor refresh rate (a classic bug on 144Hz displays!).

let lastTime = 0;
const pacman = new AIPacman({ x: 1, y: 1 });
const ghosts = [{ x: 9, y: 9 }]; // You, the player!
const map = [
  [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
  [1, 2, 2, 2, 2, 1, 2, 2, 2, 3, 1],
  [1, 2, 1, 1, 2, 1, 2, 1, 1, 2, 1],
  [1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1],
  [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
];

function gameLoop(timestamp: number) {
  const deltaTime = timestamp - lastTime;
  lastTime = timestamp;

  // Clear canvas and draw map grid
  drawMap(map);

  // Update AI Pac-Man's state and position
  pacman.update(deltaTime, ghosts, map, false);

  // Draw characters
  drawPacman(pacman.getPosition());
  drawPlayerGhost(ghosts[0]);

  requestAnimationFrame(gameLoop);
}

// Start the loop
requestAnimationFrame((time) => {
  lastTime = time;
  requestAnimationFrame(gameLoop);
});

Wrapping Up: Flipping the Perspective

Building "Pac-Man, but you're the ghost" isn't just a gimmick; it’s a brilliant exercise in systems thinking. It forces us to transition from building systems that respond immediately to user input, to building systems that must anticipate and react to a user's malicious (or in this case, competitive) intent.

The next time you're working on a service mesh, a complex UI router, or an event-driven microservices architecture, remember that you're essentially building a state machine. Just like Pac-Man transitioning from GATHERING to EVADING when a threat draws near, your microservices need to cleanly transition states when system loads spike, network partitions occur, or database connections drop.

Have you ever built an inverted game mechanic or implemented a complex state machine in your production web applications? Let's talk about it in the comments below!

Until next time, keep coding.

— Alex

Post a Comment

Previous Post Next Post