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.
The arcade Pac-Man's maze, dots, power pellets, four ghosts with the arcade's targeting and mode timing, frightened
mode, eyes returning home, lives, levels with rising speed, a high score, the intro tune and effects.
Runs on the DE10-Lite with the LCD / game-pad shield, from the library's display chain (VGA pins and LCD together).
Playable by a person with the joystick and Start, and by the AI over USB (letters), so it can be tested without hands.
The screen can be read back over USB (debug_link) for automatic checks.
Fits comfortably in the MAX 10 10M50 with the debug capture buffer: under 25 % logic, under 70 % memory.
Fully testable in simulation: a testbench renders frames and plays the game.
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
Port
Dir
Width
Meaning
clk, rst_n
in
1
25 MHz; reset = PLL not locked (the first dot fill runs on release)
tick
in
1
one clock per frame (VSYNC falling edge)
want_set, want_dir
in
1, 2
a direction request (joystick held, or a USB letter); remembered until replaced
start, invincible, pause
in
1
Start button / 's'; test aids 'i' and 'p'
rb_addr, rb_q
in, out
10, 2
dot RAM read port for the screen: {row[4:0], col[4:0]} → 0 none, 1 dot, 2 power
none 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_dispaly
the 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
Scan
640 × 480 at 25 MHz from vga_controller: pxl_x, pxl_y (32-bit, as the library defines them)
Art grid
320 × 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.
Maze
28 × 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).
Tiles
tile (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.
Directions
0 up, 1 left, 2 down, 3 right (the arcade's tie order); reverse = d ^ 2
Fixed points
Pac-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 panels
left: art columns 0..47 (6 text cells): 1UP score, HIGH, LEVEL; right: 272..319: LIVES, spare-life icons, PAC MAN
Memories and tables
Memory
Size
Where
Ports
Content
dots
1024 × 2
pac_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.
walls
61,440 × 2 (240 rows × 256)
pac_draw (M9K, ROM)
one read, addressed with the next scan pixel
0 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_tiles
31 × 3 × 28 bits
pac_logic, two instances
combinational: row → wall / dot / power bit vectors
generated from the maze text by assets/make_maze.py; the door counts as wall for movement
font8x8
64 glyphs × 8 bytes
pac_draw
combinational
library
debug capture
320 × 240 × 12
debug_link (M9K)
library
the 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).
gst
Name
Moves
Leaves when
0
HOUSE
no
its release condition (dots eaten this life, or time alive)
sequencer every tick; G_FREEZE, G_DYING, G_LEVEL from the collision and timer passes
4
G_FREEZE
30
G_PLAY
5
G_DYING
60
G_DEATH (death sound)
6
G_DEATH
126: 96 of animation, 30 blank
G_READY with one life less (actors reset), or G_OVER
7
G_LEVEL
180: the maze flashes the last 120
level + 1, G_FILL → G_READY
8
G_OVER
120, then until Start
G_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.
State
Clocks
Does
M_ACTOR
1
load the actor; acc += speed; steps = acc >> 4 (0..2). Pac-Man skips the frame after eating a dot (pac_pause).
M_STEP
1
Pac-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_PFWD
2
wanted tile open → take it; then ask about the tile ahead: wall → stop (M_DONE), else M_MOVE
M_TGT
1
choose 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_GR
2 × up to 4
ask 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_MOVE
1
one pixel along cd, wrapping in the tunnel; Pac-Man → M_EAT, a ghost → M_DONE
M_EAT..M_EAT3
3
at 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_DONE
1
write back; another step or M_NEXT
M_NEXT
1
next actor, or the collision pass
M_COLL
4
ghost 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_TIMERS
1
frightened 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
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 frames
360, 300, 240, 180, 120, then 60; flashing below 120
House release
Pinky at once; Inky 30 dots this life or 240 frames alive; Clyde 60 dots or 480 frames
Points
dot 10, power 50, ghosts 200 / 400 / 800 / 1600 per pellet, extra life at 10,000 (once)
Random
16-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:
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.
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.
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.
Spare-life icons: small left-facing Pac-Men, one per spare life.
Dots (2 × 2 at the tile centre) and power pellets (a 7-pixel disc, blinking) from rb_q.
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 / channel
Use
Shield joystick, Start
through the ADC: gamepad thresholds, see example 04. One direction at a time, priority up, left, down, right.
KEY0
Start (active low on the board, inverted here)
HEX5..HEX0
the score, BCD, leading zeros blank (the last two digits always on)
LEDR4..0
one 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 in
u l d r steer, s start, p pause toggle, i invincible toggle, m mute toggle; ? and F belong to debug_link
JTAG UART out
debug_link's status line and frame dump (scripts/de10.py send, screen)
Budgets
Logic
9,504 / 49,760 LEs (19 %); 5,016 registers; 8 of 288 9-bit multipliers
Memory
1,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 %)
Timing
met on all three clocks with the generated .sdc (create_clock on the 50 MHz input, derive_pll_clocks)
Headroom
room for fruit, pop-up scores, a second maze, or dropping the capture buffer (frees 55 % of memory)
Design decisions
No frame buffer for the game. The picture is computed per pixel from a few hundred bits of state; the only
memories are the static wall picture and the 2-bit dot map. Cheap, and nothing can tear.
Sequential game update. One actor and one pixel per few clocks instead of parallel per-actor logic: a fifth of
the logic, one tile-table port, and a state machine you can single-step in a waveform.
BCD everywhere a number is shown. Score, high score and level never need a divider; the screen and the 7-segment
read the same digits.
Tables generated by Python. The maze is a 31-line text picture in make_maze.py; it writes the Verilog
table, the wall picture (with the arcade's inset double lines computed from distance to open tiles) and a preview PNG.
Edit the text, run the script, rebuild.
Letters over USB. Five lines of decode in the top make the whole game testable by a machine.
Kept out, on purpose: the fruit, the pop-up score, the demo in attract mode. They are easy additions and are
listed on the overview page.