---
title: "GLM 5.2 pacman"
created: 2026-06-17
updated: 2026-06-17
type: summary
tags: [AI]
external: http://go/ui/posts/2778/
status: inbox
---

Here's the complete `pacman.py` implementation:

```python
#!/usr/bin/env python3
"""Pac-Man — Terminal Edition.  Run: python3 pacman.py"""

import curses
import time
import random

# ─── Maze ───────────────────────────────────────────────────────────
# 31 rows × 28 cols.  '#' wall, '.' dot, 'o' power pellet,
# ' ' empty, 'G' ghost-house door.
MAZE_TEMPLATE = [
    "############################",
    "#............##............#",
    "#.####.#####.##.#####.####.#",
    "#o####.#####.##.#####.####o#",
    "#.####.#####.##.#####.####.#",
    "#..........................#",
    "#.####.##.########.##.####.#",
    "#.####.##.########.##.####.#",
    "#......##....##....##......#",
    "######.##### ## #####.######",
    "     #.##### ## #####.#     ",
    "     #.##          ##.#     ",
    "     #.## ###--### ##.#     ",
    "######.## #      # ##.######",
    "          #      #          ",
    "######.## #      # ##.######",
    "     #.## ######## ##.#     ",
    "     #.##          ##.#     ",
    "     #.## ######## ##.#     ",
    "######.## ######## ##.######",
    "#............##............#",
    "#.####.#####.##.#####.####.#",
    "#.####.#####.##.#####.####.#",
    "#o..##.......##.......##..o#",
    "###.##.##.########.##.##.###",
    "###.##.##.########.##.##.###",
    "#......##....##....##......#",
    "#.##########.##.##########.#",
    "#.##########.##.##########.#",
    "#..........................#",
    "############################",
]

ROWS = 31
COLS = 28

# Ghost-house door position (row 12, col 14 → the two '-' cells become 'G')
# We'll patch the template after copying.

# Directions: (dr, dc)
UP    = (-1, 0)
DOWN  = (1, 0)
LEFT  = (0, -1)
RIGHT = (0, 1)
DIRS  = [UP, DOWN, LEFT, RIGHT]
OPPOSITE = {UP: DOWN, DOWN: UP, LEFT: RIGHT, RIGHT: UP}

# ─── Ghost ──────────────────────────────────────────────────────────
class Ghost:
    COLORS = {0: 1, 1: 2, 2: 3, 3: 4}          # Blinky, Pinky, Inky, Clyde
    GLYPH  = 'ᗣ'
    EATEN  = '◌'

    def __init__(self, idx, home_r, home_c, start_r, start_c):
        self.idx       = idx
        self.color     = self.COLORS[idx]
        self.home_r    = home_r
        self.home_c    = home_c
        self.start_r   = start_r
        self.start_c   = start_c
        self.reset()

    def reset(self):
        self.r = self.start_r
        self.c = self.start_c
        self.dir = UP
        self.frightened = False
        self.fright_timer = 0.0
        self.eaten = False
        self.spawn_delay = float(self.idx * 2)   # 0,2,4,6 s

    # ── helpers ────────────────────────────────────────────────────
    @staticmethod
    def _walkable(maze, r, c):
        """Ghost-walkable: not a wall."""
        return maze[r][c] != '#'

    def _blink(self):
        """True when frightened & should show white (last 2 s)."""
        return (self.frightened
                and self.fright_timer < 2.0
                and int(self.fright_timer * 3) % 2 == 0)

    # ── movement ───────────────────────────────────────────────────
    def move(self, maze, pac_r, pac_c, dt):
        if self.spawn_delay > 0:
            self.spawn_delay -= dt
            return

        # Frightened timer
        if self.frightened:
            self.fright_timer -= dt
            if self.fright_timer <= 0:
                self.frightened = False

        # Target cell
        if self.eaten:
            tr, tc = self.home_r, self.home_c
            # arrived home?
            if self.r == self.home_r and self.c == self.home_c:
                self.eaten = False
                self.frightened = False
                self.fright_timer = 0
        elif self.frightened:
            tr, tc = random.randint(0, ROWS-1), random.randint(0, COLS-1)
        else:
            offsets = [(0, 0), (2, 2), (-2, 2), (2, -2)]
            dr, dc = offsets[self.idx]
            tr, tc = pac_r + dr, pac_c + dc

        # Candidate directions (no reverse), shuffled for tie-break
        cand = [d for d in DIRS if d != OPPOSITE.get(self.dir)]
        random.shuffle(cand)

        best, best_d = None, None
        for d in cand:
            nr, nc = self.r + d[0], (self.c + d[1]) % COLS
            if not self._walkable(maze, nr, nc):
                continue
            dist = (nr - tr) ** 2 + (nc - tc) ** 2
            if best_d is None or dist < best_d:
                best_d = dist
                best  = d

        if best is None:                      # dead end → allow reverse
            for d in DIRS:
                nr, nc = self.r + d[0], (self.c + d[1]) % COLS
                if self._walkable(maze, nr, nc):
                    best = d
                    break

        if best is not None:
            self.r += best[0]
            self.c  = (self.c + best[1]) % COLS
            self.dir = best


# ─── PacmanGame ─────────────────────────────────────────────────────
class PacmanGame:
    PAC_INTERVAL   = 0.14
    GHOST_INTERVAL = 0.22
    BLINK_INTERVAL = 0.35
    FRIGHT_TIME    = 7.0

    PAC_ARROWS = {UP: 'ᐱ', DOWN: 'ᐯ', LEFT: 'ᐸ', RIGHT: 'ᐳ'}

    def __init__(self, stdscr):
        self.stdscr = stdscr
        self._setup_colors()
        self.score = 0
        self.lives = 3
        self.level = 1
        self.paused = False
        self.running = True

    # ── colour setup ───────────────────────────────────────────────
    def _setup_colors(self):
        curses.use_default_colors()
        curses.init_pair(1, curses.COLOR_RED,     -1)
        curses.init_pair(2, curses.COLOR_MAGENTA, -1)
        curses.init_pair(3, curses.COLOR_CYAN,    -1)
        curses.init_pair(4, curses.COLOR_YELLOW,  -1)
        curses.init_pair(5, curses.COLOR_YELLOW,  -1)
        curses.init_pair(6, curses.COLOR_BLUE,    -1)
        curses.init_pair(7, curses.COLOR_WHITE,   -1)
        curses.init_pair(8, curses.COLOR_GREEN,   -1)

    # ── game init ──────────────────────────────────────────────────
    def _init_game(self):
        self.maze = [list(row) for row in MAZE_TEMPLATE]

        # Mark ghost-house door cells (row 12, cols 13-14) as 'G'
        for c in (13, 14):
            if 0 <= 12 < ROWS and 0 <= c < COLS:
                self.maze[12][c] = 'G'

        self.score = 0
        self.lives = 3
        self.level = 1
        self.dots_eaten = 0
        self.total_dots = sum(
            1 for r in range(ROWS) for c in range(COLS)
            if self.maze[r][c] in ('.', 'o')
        )

        # Pac-Man
        self.pac_r = 23
        self.pac_c = 14
        self.pac_dir = LEFT
        self.next_dir = LEFT

        # Ghosts — all start inside the house
        self.ghosts = [
            Ghost(0, 14, 13, 14, 13),   # Blinky  (door cell-ish)
            Ghost(1, 14, 13, 14, 12),   # Pinky
            Ghost(2, 14, 14, 15, 12),   # Inky
            Ghost(3, 14, 14, 15, 14),   # Clyde
        ]

        self.last_pac   = time.time()
        self.last_ghost = time.time()
        self.last_blink = time.time()
        self.blink_on   = True
        self.pac_mouth  = True

    # ── public entry ───────────────────────────────────────────────
    def run(self):
        self._show_splash()
        while self.running:
            self._init_game()
            self._play()
            if not self._game_over_screen():
                break

    # ── splash ─────────────────────────────────────────────────────
    def _show_splash(self):
        s = self.stdscr
        s.nodelay(False)
        s.clear()
        sh, sw = s.getmaxyx()

        title_lines = [
            "██████╗  █████╗ ██████╗  █████╗ ██╗   ██╗",
            "██╔══██╗██╔══██╗██╔══██╗██╔══██╗██║   ██║",
            "██████╔╝███████║██████╔╝███████║██║   ██║",
            "██╔═══╝ ██╔══██║██╔══██╗██╔══██║██║   ██║",
            "██║     ██║  ██║██████╔╝██║  ██║╚██████╔╝",
            "╚═╝     ╚═╝  ╚═╝╚═════╝ ╚═╝  ╚═╝ ╚═════╝ ",
        ]
        top = max(0, (sh - 12) // 2)
        for i, line in enumerate(title_lines):
            col = max(0, (sw - len(line)) // 2)
            try:
                s.addstr(top + i, col, line, curses.color_pair(5) | curses.A_BOLD)
            except curses.error:
                pass

        info_top = top + len(title_lines) + 2
        ghosts_line = (
            "Blinky ᗣ  Pinky ᗣ  Inky ᗣ  Clyde ᗣ"
        )
        ctrl_line = "Arrow keys / WASD: move   P: pause   Q: quit"
        start_line = "Press any key to start!"

        for i, (line, pair) in enumerate([
            (ghosts_line, 7),
            (ctrl_line,   7),
            (start_line,  8),
        ]):
            col = max(0, (sw - len(line)) // 2)
            try:
                s.addstr(info_top + i, col, line,
                         curses.color_pair(pair) | curses.A_BOLD)
            except curses.error:
                pass

        s.refresh()
        s.getch()

    # ── main play loop ─────────────────────────────────────────────
    def _play(self):
        s = self.stdscr
        s.nodelay(True)
        curses.curs_set(0)
        now = time.time()
        self.last_pac = now
        self.last_ghost = now
        self.last_blink = now

        playing = True
        while playing:
            now = time.time()
            dt = now - self._last_frame if hasattr(self, '_last_frame') else 0.016
            self._last_frame = now

            if self._handle_input() == 'QUIT':
                self.running = False
                return

            if not self.paused:
                # Pac-Man
                if now - self.last_pac >= self.PAC_INTERVAL:
                    self._move_pacman()
                    self.last_pac = now
                    self.pac_mouth = not self.pac_mouth

                # Ghosts
                if now - self.last_ghost >= self.GHOST_INTERVAL:
                    for g in self.ghosts:
                        g.move(self.maze, self.pac_r, self.pac_c,
                               self.GHOST_INTERVAL)
                    self.last_ghost = now

                # Blink timer
                if now - self.last_blink >= self.BLINK_INTERVAL:
                    self.blink_on = not self.blink_on
                    self.last_blink = now

                result = self._check_collisions()
                if result == 'LOSE':
                    playing = False
                    continue
                if self._check_win():
                    playing = False
                    self.won = True
                    continue

            self._draw()
            time.sleep(0.02)

    # ── input ──────────────────────────────────────────────────────
    def _handle_input(self):
        s = self.stdscr
        while True:
            ch = s.getch()
            if ch == -1:
                break
            if ch in (curses.KEY_UP, ord('w'), ord('W')):
                self.next_dir = UP
            elif ch in (curses.KEY_DOWN, ord('s'), ord('S')):
                self.next_dir = DOWN
            elif ch in (curses.KEY_LEFT, ord('a'), ord('A')):
                self.next_dir = LEFT
            elif ch in (curses.KEY_RIGHT, ord('d'), ord('D')):
                self.next_dir = RIGHT
            elif ch in (ord('p'), ord('P')):
                self.paused = not self.paused
            elif ch in (ord('q'), ord('Q')):
                return 'QUIT'
        return None

    # ── Pac-Man movement ───────────────────────────────────────────
    def _move_pacman(self):
        maze = self.maze

        def try_dir(d):
            nr = self.pac_r + d[0]
            nc = (self.pac_c + d[1]) % COLS
            cell = maze[nr][nc]
            if cell == '#' or cell == 'G':
                return False
            return True

        d = self.next_dir if try_dir(self.next_dir) else (
            self.pac_dir if try_dir(self.pac_dir) else None
        )
        if d is None:
            return

        self.pac_r += d[0]
        self.pac_c  = (self.pac_c + d[1]) % COLS
        self.pac_dir = d

        cell = self.maze[self.pac_r][self.pac_c]
        if cell == '.':
            self.maze[self.pac_r][self.pac_c] = ' '
            self.score += 10
            self.dots_eaten += 1
        elif cell == 'o':
            self.maze[self.pac_r][self.pac_c] = ' '
            self.score += 50
            self.dots_eaten += 1
            for g in self.ghosts:
                if not g.eaten:
                    g.frightened = True
                    g.fright_timer = self.FRIGHT_TIME

    # ── collisions ─────────────────────────────────────────────────
    def _check_collisions(self):
        for g in self.ghosts:
            if g.r == self.pac_r and g.c == self.pac_c:
                if g.eaten:
                    continue
                if g.frightened:
                    g.eaten = True
                    g.frightened = False
                    self.score += 200
                else:
                    self._lose_life()
                    return 'LOSE'
        return None

    def _lose_life(self):
        self.lives -= 1
        if self.lives <= 0:
            self.won = False
            return
        # reset positions
        self.pac_r, self.pac_c = 23, 14
        self.pac_dir = LEFT
        self.next_dir = LEFT
        for g in self.ghosts:
            g.reset()
        self._draw()
        time.sleep(1.0)

    def _check_win(self):
        if self.dots_eaten >= self.total_dots:
            self.won = True
            return True
        return False

    # ── rendering ──────────────────────────────────────────────────
    def _addch(self, r, c, ch, attr=0):
        try:
            self.stdscr.addch(r, c, ch, attr)
        except curses.error:
            pass

    def _draw(self):
        s = self.stdscr
        sh, sw = s.getmaxyx()
        offset_r = max(0, (sh - ROWS - 3) // 2)
        offset_c = max(0, (sw - COLS) // 2)

        s.clear()

        # maze
        for r in range(ROWS):
            for c in range(COLS):
                cell = self.maze[r][c]
                R, C = offset_r + r, offset_c + c
                if cell == '#':
                    self._addch(R, C, '█', curses.color_pair(7) | curses.A_DIM)
                elif cell == '.':
                    self._addch(R, C, '·', curses.color_pair(7))
                elif cell == 'o':
                    self._addch(R, C, '●', curses.color_pair(7) | curses.A_BOLD)
                elif cell == 'G':
                    self._addch(R, C, '─', curses.color_pair(7) | curses.A_DIM)
                # ' ' → nothing

        # ghosts
        for g in self.ghosts:
            R, C = offset_r + g.r, offset_c + g.c
            if g.eaten:
                self._addch(R, C, Ghost.EATEN, curses.color_pair(7) | curses.A_BOLD)
            elif g.frightened:
                if g._blink():
                    attr = curses.color_pair(7) | curses.A_BOLD
                else:
                    attr = curses.color_pair(6) | curses.A_BOLD
                self._addch(R, C, Ghost.GLYPH, attr)
            else:
                self._addch(R, C, Ghost.GLYPH,
                            curses.color_pair(g.color) | curses.A_BOLD)

        # Pac-Man
        R, C = offset_r + self.pac_r, offset_c + self.pac_c
        if self.pac_mouth:
            glyph = self.PAC_ARROWS.get(self.pac_dir, 'O')
        else:
            glyph = 'O'
        self._addch(R, C, glyph, curses.color_pair(5) | curses.A_BOLD)

        # HUD
        hud_r = offset_r + ROWS + 1
        lives_str = ' '.join(['●'] * max(0, self.lives))
        hud = f"SCORE: {self.score:06d}   LIVES: {lives_str:<5} LEVEL: {self.level}"
        if self.paused:
            hud += "   [PAUSED]"
        hud_c = max(0, (sw - len(hud)) // 2)
        try:
            s.addstr(hud_r, hud_c, hud, curses.color_pair(8) | curses.A_BOLD)
        except curses.error:
            pass

        s.refresh()

    # ── game-over screen ───────────────────────────────────────────
    def _center_msg(self, r, msg, attr=0):
        s = self.stdscr
        sh, sw = s.getmaxyx()
        c = max(0, (sw - len(msg)) // 2)
        try:
            s.addstr(r, c, msg, attr)
        except curses.error:
            pass

    def _game_over_screen(self):
        """Return True to play again, False to exit."""
        s = self.stdscr
        s.nodelay(False)
        s.clear()
        sh, sw = s.getmaxyx()
        mid = sh // 2

        if getattr(self, 'won', False):
            self._center_msg(mid - 2, "YOU WIN! 🎉",
                             curses.color_pair(8) | curses.A_BOLD)
        else:
            self._center_msg(mid - 2, "GAME OVER",
                             curses.color_pair(1) | curses.A_BOLD)

        self._center_msg(mid, f"Final Score: {self.score}",
                         curses.color_pair(7) | curses.A_BOLD)
        self._center_msg(mid + 2, "Play again? (Y / N)",
                         curses.color_pair(7))

        s.refresh()
        while True:
            ch = s.getch()
            if ch in (ord('y'), ord('Y')):
                return True
            if ch in (ord('n'), ord('N'), ord('q'), ord('Q')):
                return False


# ─── entry point ────────────────────────────────────────────────────
def main(stdscr):
    game = PacmanGame(stdscr)
    game.run()


if __name__ == "__main__":
    curses.wrapper(main)
```

## Quick reference

| Item | Detail |
|------|--------|
| **Run** | `python3 pacman.py` |
| **Move** | Arrow keys or WASD |
| **Pause** | `P` |
| **Quit** | `Q` |
| **Min terminal** | 80 × 34 |

## How it works

<details>
<summary><b>Maze &amp; cells</b></summary>

`MAZE_TEMPLATE` is a 31×28 grid of strings. At `_init_game()` it's deep-copied to a list-of-lists so dots/pellets can be consumed. The two `-` cells at row 12, cols 13–14 are patched to `'G'` (ghost-house door). Row 14's left/right edges are spaces — horizontal tunnel wrap uses `col % COLS`.
</details>

<details>
<summary><b>Pac-Man movement &amp; cornering</b></summary>

Both `pac_dir` (current) and `next_dir` (buffered) are kept. On each 0.14 s tick the code tries `next_dir` first; if the cell ahead is a wall or door it falls back to `pac_dir`. The mouth glyph alternates between the directional arrow and `'O'` every tick.
</details>

<details>
<summary><b>Ghost AI</b></summary>

Each ghost picks the non-reverse, walkable neighbour with the smallest squared-Euclidean distance to its target. Targets:
- **Eaten** → home cell (returns to house, resets).
- **Frightened** → random cell (scatter).
- **Normal** → Pac-Man position + per-ghost offset `[(0,0),(2,2),(-2,2),(2,-2)]`.

Directions are shuffled so ties break randomly. The door `'G'` is walkable for ghosts but not Pac-Man.
</details>

<details>
<summary><b>Frightened &amp; eaten states</b></summary>

Eating a power pellet (`'o'`) sets `frightened=True` for 7 s on all non-eaten ghosts. During the last 2 s they blink white↔blue on the 0.35 s timer. Colliding with a frightened ghost awards 200 pts and marks it `eaten` (glyph `◌`, navigates home). Colliding with a normal ghost costs a life.
</details>

<details>
<summary><b>Rendering</b></summary>

The maze is centred via `offset_r / offset_c`. Every `addstr`/`addch` is wrapped in `try/except curses.error` so small terminals degrade gracefully. The HUD is green/bold one row below the maze. The splash screen shows a big block-letter title; the game-over screen reports win/lose and prompts Y/N.
</details>

Save as `pacman.py` and run — enjoy! 🟡