Pac-Man: architecture spec

What each module is, what crosses between them, where every bit of state lives, and the budgets (clocks per frame, memory, logic). Written for someone who will change the design or build the next game on it.

Contents

  1. Requirements
  2. Modules and hierarchy
  3. Clock domains and crossings
  4. Coordinate system and screen geometry
  5. Memories and tables
  6. pac_logic: state, sequencer, encodings
  7. pac_draw: the pixel pipeline
  8. pac_sound
  9. Board I/O and the USB protocol
  10. Budgets
  11. Design decisions

Requirements

Modules and hierarchy

pacman_top                      board level, 25/50/100 MHz
├─ pll25 (lib)                   50 MHz in → 25, 50, 100 MHz, locked
├─ Screens_dispaly (lib)         VGA timing (pxl_x, pxl_y, sync) + LCD copy
├─ gamepad (lib) + adc IP        the shield's joystick and buttons, 50 MHz
├─ debounce (lib)                8 buttons, 5 ms steady
├─ pac_logic                     the game
│  ├─ maze_tiles  (q_tiles)      tile table, query port (walls)
│  ├─ maze_tiles  (f_tiles)      tile table, fill port (dots, power)
│  └─ dots[1024]                 inferred dual-port RAM, 2 bits
├─ pac_draw                      the picture
│  ├─ walls[61440]               inferred ROM, 2 bits ($readmemb assets/walls.bin)
│  ├─ ghost_sprite ×4            14 × 14 ghost, colour parameter
│  └─ font8x8 (lib)              8 × 8 glyphs, ASCII 0x20..0x5F
├─ pac_sound                     the buzzer
├─ seven_segment ×6 (lib)        BCD score
├─ debug_link (lib)              '?' status, 'F' frame dump; the rx stream is shared with the game's letters
└─ jtag_uart (lib)               the USB link (alt_jtag_atlantic)

pac_logic ports

PortDirWidthMeaning
clk, rst_nin125 MHz; reset = PLL not locked (the first dot fill runs on release)
tickin1one clock per frame (VSYNC falling edge)
want_set, want_dirin1, 2a direction request (joystick held, or a USB letter); remembered until replaced
start, invincible, pausein1Start button / 's'; test aids 'i' and 'p'
rb_addr, rb_qin, out10, 2dot RAM read port for the screen: {row[4:0], col[4:0]} → 0 none, 1 dot, 2 power
pac_x, pac_y, pac_dir, pac_mouthout8, 8, 2, 2Pac-Man in maze pixels; mouth 0 closed, 1 half, 2 wide
death_phase, dying_anim, pac_showout4, 1, 1the death animation (phase 0..11), and whether to draw him at all
g_x, g_y, g_dir, g_state, g_frightout32, 32, 8, 12, 4the four ghosts packed: ghost g (1..4) in bits [8g-8 +: 8], [2g-2 +: 2], [3g-3 +: 3], [g-1]
ghosts_show, fright_flashout1hide the ghosts (death, game over); frightened time nearly over
score, hiscore, lives, level_bcdout24, 24, 4, 8BCD, 6 digits; lives includes the one in play; level 1..99 BCD
show_ready, show_gameover, show_push, maze_whiteout1messages and the level-clear flash
frameout8frame counter for animation (bit 3 = 8-frame blink)
snd_dot, snd_power, snd_ghost, snd_death, snd_intro, fright_onout1one-clock sound triggers; fright_on is a level

Clock domains and crossings

DomainWhat runs thereCrossing
50 MHz (pll c1)ADC IP, gamepad, debounce→ 25 MHz: two flip-flops per button in pacman_top. Buttons are slow levels, so no handshake is needed; a one-frame delay is invisible.
25 MHz (pll c0)VGA timing, pac_logic, pac_draw, pac_sound, debug_link, jtag_uart, 7-segmentnone inside: the screen reads the game's registers directly. They change only during vertical blank (the sequencer runs right after tick), so a frame is always drawn from one consistent state.
100 MHz (pll c2)lcd_ctrl inside Screens_dispalythe library's own; it samples the 25 MHz rgb and syncs.

The JTAG UART's rx_valid is a 25 MHz pulse. The game's letters are decoded in pacman_top; 's' is held in a register until the next tick so the game, which samples start only at ticks, cannot miss it.

Coordinate system and screen geometry

Scan640 × 480 at 25 MHz from vga_controller: pxl_x, pxl_y (32-bit, as the library defines them)
Art grid320 × 240: lx = pxl_x[9:1], ly = pxl_y[8:1]; every art pixel is 2 × 2 scan pixels. The USB capture (STEP 2) is exactly this grid.
Maze28 × 31 tiles of 8 = 224 × 248 maze pixels, at art columns 48..271. mx = lx - 48, my = ly + 4: 4 rows of the outer wall are cut at the top and the bottom (the maze is 8 rows taller than the screen).
Tilestile (c, r) = (x >> 3, y >> 3); the centre of a tile is x[2:0] = 4, y[2:0] = 4. Decisions happen only at centres.
Directions0 up, 1 left, 2 down, 3 right (the arcade's tie order); reverse = d ^ 2
Fixed pointsPac-Man start (112, 188); the house door (112, 92); inside the house y = 116, Inky at x 96, Clyde at x 128; the tunnel is row 14, open past both edges, x wraps 0 ↔ 223
Side panelsleft: art columns 0..47 (6 text cells): 1UP score, HIGH, LEVEL; right: 272..319: LIVES, spare-life icons, PAC MAN

Memories and tables

MemorySizeWherePortsContent
dots1024 × 2pac_logic (M9K)A: the sequencer reads and clears, FILL writes; B: the screen reads{row, col} → 0 empty, 1 dot (10 pts), 2 power pellet (50 pts). 244 non-zero entries after a fill.
walls61,440 × 2 (240 rows × 256)pac_draw (M9K, ROM)one read, addressed with the next scan pixel0 nothing, 1 wall line (blue, white when the level flashes), 2 the house door (pink). Row 0 of the file is maze pixel row 4. Columns 224..255 are padding.
maze_tiles31 × 3 × 28 bitspac_logic, two instancescombinational: row → wall / dot / power bit vectorsgenerated from the maze text by assets/make_maze.py; the door counts as wall for movement
font8x864 glyphs × 8 bytespac_drawcombinationallibrary
debug capture320 × 240 × 12debug_link (M9K)librarythe last frame, dumped on 'F'. Not part of the game: 921,600 bits.

Both big memories answer one clock after the address. The screen therefore computes the address from the next scan pixel (pxl_x + 1) and uses the answer for the current one; the wall lookup and the dot lookup both work this way, so no pixel is shifted. The testbench primes each line by starting at pxl_x = -1.

pac_logic: state, sequencer, encodings

Per-actor registers

Five actors, index 0 Pac-Man, 1 Blinky, 2 Pinky, 3 Inky, 4 Clyde: ax, ay (8-bit maze pixels), ad (2-bit direction), acc (5-bit speed accumulator, sixteenths), gst (3-bit ghost state), gfr (frightened).

gstNameMovesLeaves when
0HOUSEnoits release condition (dots eaten this life, or time alive)
1LEAVEto x = 112, then up to y = 92, ignoring wallsat the door: ACTIVE, facing left
2ACTIVEthe maze, by targeteaten → EYES; or a collision kills Pac-Man
3EYEStarget the door tile (13, 11), double speedat a door tile centre → ENTER
4ENTERto x = 112, then down to y = 116LEAVE, no longer frightened

Game state (gs) and its timer (gtimer, frames)

gsNameFramesNext
0G_ATTRACTuntil StartG_FILL → G_READY (new_game: score, lives, level reset; intro tune)
1G_FILL1024 clocks, not framesgs_after_fill; resets the actors, dots_left = 244
2G_READY120, or 260 on a new gameG_PLAY
3G_PLAY-sequencer every tick; G_FREEZE, G_DYING, G_LEVEL from the collision and timer passes
4G_FREEZE30G_PLAY
5G_DYING60G_DEATH (death sound)
6G_DEATH126: 96 of animation, 30 blankG_READY with one life less (actors reset), or G_OVER
7G_LEVEL180: the maze flashes the last 120level + 1, G_FILL → G_READY
8G_OVER120, then until StartG_FILL → G_READY

The micro-sequencer (ms)

Started by a tick in G_PLAY. One actor (a) at a time; the actor's registers are copied into working registers cx, cy, cd, cst and written back in M_DONE after every pixel step.

StateClocksDoes
M_ACTOR1load the actor; acc += speed; steps = acc >> 4 (0..2). Pac-Man skips the frame after eating a dot (pac_pause).
M_STEP1Pac-Man: a reverse request is taken at once; at a centre ask about the wanted tile → M_PWANT, else M_MOVE. Ghost: by cst: HOUSE waits; LEAVE / ENTER steer by coordinates; ACTIVE / EYES at a centre → M_TGT (EYES at a door tile → ENTER), else M_MOVE.
M_PWANT, M_PFWD2wanted tile open → take it; then ask about the tile ahead: wall → stop (M_DONE), else M_MOVE
M_TGT1choose the target tile (EYES: the door; scatter: the corner; chase: the ghost's rule); start k at 0, or at a random direction when frightened
M_GQ, M_GR2 × up to 4ask about direction k; frightened: the first open non-reverse way wins; else keep the open way with the smallest squared distance to the target (ties: the lower k). After 4 tries take the best (or reverse if all shut, which this maze never does).
M_MOVE1one pixel along cd, wrapping in the tunnel; Pac-Man → M_EAT, a ghost → M_DONE
M_EAT..M_EAT33at a centre read the dot RAM under Pac-Man (2 clocks), then clear it, add 10 / 50 in BCD, count, set pac_pause; a power pellet starts fr_timer, frightens ACTIVE / HOUSE / LEAVE ghosts and reverses the active ones
M_DONE1write back; another step or M_NEXT
M_NEXT1next actor, or the collision pass
M_COLL4ghost cg vs Pac-Man: ACTIVE and |dx| ≤ 4 and |dy| ≤ 4: frightened → EYES, points 200 · 2^chain, G_FREEZE; else G_DYING unless invincible. Only in G_PLAY, so a second ghost on the same frame waits for the freeze to end.
M_TIMERS1frightened countdown (all gfr cleared at 1); scatter/chase mode steps (420, 1200, 420, 1200, 300, 1200, 300 frames, then chase for ever; each change reverses ACTIVE ghosts; paused while frightened); house releases; last dot → G_LEVEL

Longest frame: 5 actors × 2 steps × about 12 clocks + 4 + 1 ≈ 130 clocks. Vertical blank is 36,000.

Numbers that tune the game

Speeds (1/16 px per frame)Pac-Man 16 (level 1), 18 after; +2 while frightened. Ghosts 15 (level 1), 16 (2-4), 17 (5+); tunnel 7; frightened 8; eyes 32; in/out of the house 8
Frightened frames360, 300, 240, 180, 120, then 60; flashing below 120
House releasePinky at once; Inky 30 dots this life or 240 frames alive; Clyde 60 dots or 480 frames
Pointsdot 10, power 50, ghosts 200 / 400 / 800 / 1600 per pellet, extra life at 10,000 (once)
Random16-bit LFSR (taps 16, 14, 13, 11), free running at 25 MHz; a ghost takes two bits at decision time

pac_draw: the pixel pipeline

Purely combinational from (pxl_x, pxl_y) and the game's registers, plus the two memories addressed one pixel ahead. For every pixel, in priority order:

  1. Ghosts (Blinky in front): ghost_sprite gives hit and a colour. The body is a 14-row bitmap with two skirt frames (frame[3]); eyes are two 4 × 5 whites shifted a pixel toward the direction with 2 × 2 pupils; frightened: blue body, a wavy mouth, white/red while flashing; EYES / ENTER: only the eyes.
  2. Pac-Man: a 13-pixel disc on half-pixel centres (dx2 = 2(mx - x) + 1, dx2² + dy2² ≤ 169) minus a wedge: the pixel is in the mouth when a·sin(t) > |b|·cos(t), with (a, b) the pixel rotated into the facing direction and t the half-opening (0°, 25°, 45° by pac_mouth; 15°..180° by death_phase while dying). Products go through 12-bit wires: the compare would otherwise be done at 6 bits.
  3. Text: one font8x8; the character comes from the panel tables (screen grid) or the maze messages (READY!, GAME OVER, PUSH START on tile row 17). Numbers are BCD digits with leading zeros blanked, the last two always shown.
  4. Spare-life icons: small left-facing Pac-Men, one per spare life.
  5. Dots (2 × 2 at the tile centre) and power pellets (a 7-pixel disc, blinking) from rb_q.
  6. Walls from the ROM: blue, white while maze_white; the door pink.

pac_sound

A square wave: a 16-bit counter toggles the output when it reaches half (clocks per half period at 25 MHz, half = 25e6 / (2f)). half is chosen once per frame from the highest-priority effect still running: death (96 frames, falling with a warble), the intro tune (31 notes, {half, frames} table, 256 frames), ghost eaten (30 frames, rising), power pellet (40 frames, two low notes), dot (6 frames, two notes whose order alternates: "waka"). 'm' mutes. The output drives ARDUINO_IO[12], the shield's buzzer line.

Board I/O and the USB protocol

Pin / channelUse
Shield joystick, Startthrough the ADC: gamepad thresholds, see example 04. One direction at a time, priority up, left, down, right.
KEY0Start (active low on the board, inverted here)
HEX5..HEX0the score, BCD, leading zeros blank (the last two digits always on)
LEDR4..0one LED per spare life; LEDR8 pause, LEDR9 invincible
ARDUINO_IO[7:0], [11:8]the LCD data and control lines (library layout)
ARDUINO_IO[12], [13]buzzer; the shield's status LED = frightened mode on
JTAG UART inu l d r steer, s start, p pause toggle, i invincible toggle, m mute toggle; ? and F belong to debug_link
JTAG UART outdebug_link's status line and frame dump (scripts/de10.py send, screen)

Budgets

Logic9,504 / 49,760 LEs (19 %); 5,016 registers; 8 of 288 9-bit multipliers
Memory1,063,680 / 1,677,312 bits (63 %), of which the game is 124,928 + 2,048 and the rest is the debug capture, the LCD command ROM and the ADC sample store
Clocks per frame≈ 130 used of 36,000 available in vertical blank (0.4 %)
Timingmet on all three clocks with the generated .sdc (create_clock on the 50 MHz input, derive_pll_clocks)
Headroomroom for fruit, pop-up scores, a second maze, or dropping the capture buffer (frees 55 % of memory)

Design decisions