Nand2Tetris Basys3 / Artix-7 16-bit CPU VGA PS/2 Keyboard Verilog

NAND to Tetris, on Real Silicon

I wanted to understand a computer from the very bottom — not the API, not the ISA, but the gates. So I took the Nand2Tetris course, and then did the thing the course stops just short of: I built the whole Hack computer in Verilog, from a single NAND up to a 16-bit CPU with VGA and a keyboard, synthesized it onto a Digilent Basys3, and played a game on a real monitor.

1→∞
one NAND, whole computer
16-bit
Hack CPU
100 MHz
+1.74 ns slack (MET)
512×256
VGA framebuffer
7
testbenches, all green
▶ Play the game (in your browser) GitHub Repository The Course →
Act 1 — The itch
I wanted to see the bottom of the stack

I spend my days as an FPGA engineer, high up the abstraction ladder — AXI, PetaLinux, AI Engines. But a nagging question kept returning: if you keep asking “and what is that made of?” all the way down, where do you land? What is the smallest true thing a computer is built from?

The answer, famously, is a single logic gate. That’s the premise of Nand2Tetris“The Elements of Computing Systems” by Nisan and Schocken, which I took through Coursera’s Build a Modern Computer from First Principles. You start with one primitive, the NAND gate, and climb: from NAND you build every other gate, from gates an ALU, from the ALU and some flip-flops a CPU, and from the CPU a whole machine that runs software you also write.

The course’s promise is irresistible: nothing is a black box. Every layer is something you built out of the layer below, and the only thing you took on faith is a single NAND gate.
Act 2 — The twist
The course stops at an emulator. I had a Basys3 on my desk.

Nand2Tetris is brilliant, but it lives in software: you draw your chips in a teaching HDL and test them in a hardware simulator, and the final “computer” runs inside a CPU emulator. It’s the right pedagogy — but the machine never becomes a machine. There is no clock you can probe, no pixel that is actually lit, no key that is actually pressed.

Sitting in a drawer was a Digilent Basys3 — a Xilinx Artix-7 (xc7a35t) board with a VGA port and a USB host that speaks PS/2. So the project chose itself: take the Hack computer off the emulator and put it on silicon. Re-write the chips in real Verilog, make the memories real block RAM, give it a VGA controller for eyes and a keyboard decoder for ears, close timing, generate a bitstream — and play a game on an actual monitor.

The one rule

Stay honest to the spirit of Nand2Tetris: the datapath is still composed from the course’s gate-level chips (the ALU is literally muxes, adders and Or8Way), not a behavioral a+b. The FPGA only gets behavioral help where physics demands it — the large memories, which must be block RAM.

Act 3 — The ladder
From one gate to a whole computer

The Hack architecture is a clean climb. Each rung is built only from the rungs beneath it — this is the whole point. The repo I started from had the lower rungs (the combinational and sequential chips), but the top of the ladder — the parts that make it a computer — was missing: PC.v was empty, Inc16.v was a stub, and there was no CPU, memory, ROM, or top level at all.

L0NANDthe single primitive — the one thing taken on faith
L1GatesNot, And, Or, Xor, Mux, DMux — all from NAND
L216-bit & multi-wayNot16, And16, Mux16, Mux4Way16, Or8Way, DMux8Way
L3ArithmeticHalfAdder → FullAdder → Add16 → Inc16 → ALU
L4MemoryDFF → Bit → Register → RAM8 → RAM64 → … → RAM16K
L5CPU + PCA/D registers, the ALU, jump logic, program counter — built here
L6ComputerCPU + instruction ROM + data Memory with mapped I/O — built here
L7Basys3 topclocking, VGA, PS/2, constraints, bitstream — the FPGA layer

The ALU is a good sanity check that this is the real thing and not a shortcut: it computes its 18 functions by zeroing/negating its inputs, choosing between x&y and x+y, and optionally negating the output — all wired from the course’s own Mux16, Add16 and Or8Way chips. Before touching the FPGA I completed and fixed the ladder, including two latent gate bugs (DMux and Or16 had swapped primitive terminals), then proved the new rungs in simulation.

Act 4 — Emulator → silicon
Where the abstraction meets physics

In the Nand2Tetris emulator, memory is combinational: you put an address on A and the value at RAM[A] is instantly there in the same cycle. That is a lie the emulator tells you for free. On an Artix-7, the only way to fit 32K×16 of ROM plus 16K of RAM plus an 8K framebuffer is block RAM — and block RAM is synchronous: you present an address on one clock edge and the data arrives on the next.

That one-cycle read latency is exactly the kind of detail the emulator hides and hardware won’t. The fix is a small, clean idea: run everything on one 100 MHz clock, but only let the CPU step once every 16 clocks via a clock-enable (cpu_en). The block RAMs are read every clock, so between two CPU steps the pc/address lines have been stable for many cycles and the read data is long since valid. The latency is real — it’s just fully hidden in the gap between steps. No gated clocks, no negedge tricks, clean single-edge inference.

flowchart LR OSC["100 MHz
oscillator (W5)"] --> DIV["clock-enable
divider"] DIV -->|pix_en 25 MHz| VGA["VGA controller"] DIV -->|cpu_en ~6 MHz| CPU["Hack CPU"] CPU -->|pc| ROM["Instruction ROM
(block RAM)"] ROM -->|instruction| CPU CPU -->|addressM / outM / writeM| MEM["Data Memory
(block RAM)"] MEM -->|inM| CPU MEM --- SCR["Screen buffer
512x256 (dual-port)"] SCR -->|pixels| VGA KBD["PS/2 keyboard
decoder"] -->|key code| MEM VGA -->|RGB + sync| MON["VGA monitor"] style CPU fill:#0d2438,stroke:#00d9ff,color:#e6e8eb style ROM fill:#12261c,stroke:#00ff9f,color:#e6e8eb style MEM fill:#12261c,stroke:#00ff9f,color:#e6e8eb style SCR fill:#12261c,stroke:#00ff9f,color:#e6e8eb

The CPU’s registers only latch on cpu_en, so the long combinational path (instruction → decode → ripple-carry ALU → next PC) is genuinely a multicycle path — a fact I had to tell the timing engine before it would close (see the war-stories). The result meets timing at 100 MHz with room to spare.

The memory map

The CPU sees one 15-bit data address space; three regions live inside it. The screen and keyboard are just memory — write a word to 16384 and 16 pixels light up; read 24576 and you get whatever key is held.

AddressRegionSizeMeaning
0x0000–0x3FFFData RAM16K wordsgeneral read/write memory & variables
0x4000–0x5FFFScreen8K words512×256 monochrome framebuffer (16 px / word)
0x6000Keyboard1 wordcurrent key code (0 = none), read-only
One instruction, sixteen bits

The whole ISA is two instruction types. If the top bit is 0 it’s an A-instruction (load a 15-bit constant into A). If it’s 1 it’s a C-instruction, packed like this:

15141312 11109876 543 210
111 a c1c2c3c4c5c6 d1d2d3 j1j2j3
op = C comp (A or M as y; zx nx zy ny f no) dest A/D/M jump < = >

That is the entire decode job of the CPU: split those fields, feed comp to the ALU, route the result to the chosen destinations, and load the program counter with A when the jump condition matches.

Act 5 — Eyes and ears
Giving the machine a monitor and a keyboard

A computer you can’t see or touch isn’t much fun. Two peripherals turn the Hack core into something you can actually play with.

🖥️

VGA — the eyes

A 640×480 @ 60 Hz controller (25 MHz pixel clock via pix_en) paints the 512×256 Hack framebuffer into the top-left of the frame. The screen buffer is a true dual-port block RAM: the CPU writes it as memory while the VGA scans it out as pixels — no arbitration, no tearing. Horizontal/vertical timing was verified to the exact tick (period 800/96, 525×800).

⌨️

PS/2 — the ears

The Basys3 USB host presents a keyboard as a PS/2 device. A receiver samples the 11-bit frames on the falling edge of PS2Clk, tracks the E0/F0 extended & release prefixes, and translates Set-2 scancodes into the Nand2Tetris key codes — letters, digits, and the arrow keys (130–133) the game needs.

The CPU itself stays faithful to the course design — A and D registers, the composed ALU, jump logic, and the program counter — with one addition for the FPGA: a step-enable so a single fast clock can drive the block RAMs while the CPU advances slowly.

CPU.v — instruction decode and the ALU, wired from the course chips

wire isC =  instruction[15];   // 1 => C-instruction
wire isA = ~instruction[15];   // 1 => A-instruction

// A register: A-instruction value, else ALU result
Mux16 muxA(.a(instruction), .b(aluOut), .s(isC), .out(aRegIn));
wire loadA = isA | (isC & instruction[5]);           // dest A
Register aRegister(.in(aRegIn), .load(loadA & en), .clk(clk), .out(aReg));

// ALU y input: A register or M (inM), chosen by the a-bit
Mux16 muxY(.a(aReg), .b(inM), .s(instruction[12]), .out(aluY));

ALU alu(.x(dReg), .y(aluY),
        .zx(instruction[11]), .nx(instruction[10]),
        .zy(instruction[9]),  .ny(instruction[8]),
        .f(instruction[7]),   .no(instruction[6]),
        .out(aluOut), .zr(zr), .ng(ng));

assign writeM = isC & instruction[3];                // dest M
wire pos = ~ng & ~zr;
wire doJump = isC & ((instruction[2] & ng) | (instruction[1] & zr) | (instruction[0] & pos));
PC pc0(.in(aReg), .load(doJump & en), .inc(en), .reset(reset), .clk(clk), .out(pcOut));
Proving it, rung by rung

Nothing went to the board on faith. Every layer has a self-checking Verilog testbench, run in Vivado’s xsim — from single chips up to the full machine executing real programs and the game logic:

tb_core Inc16 / PC .................... ALL PASS tb_cpu Add (2+3=5), Sum(1..10)=55 .... ALL PASS tb_computer memory-mapped screen + kbd .... ALL PASS tb_vga 640x480 timing (800/96 h, 525 v) ALL PASS tb_ps2/top PS/2 key -> CPU -> screen ...... ALL PASS tb_game block draws & moves on VGA ..... ALL PASS tb_tetris falls, locks, stacks, respawns . ALL PASS
Act 6 — The bugs worth remembering
Where “works in the emulator” met the real board

The distance between a passing simulation and a working monitor was, as always, where the actual engineering happened. Three that cost real time:

The ripple-carry ALU wouldn’t close timing — until I told the truth about the clock
First implementation: WNS = -0.673 ns. The critical path was ROM → instruction decode → the 16-bit ripple-carry Add16 → the PC register — far too long for a 10 ns period. But it doesn’t need to fit in 10 ns: the CPU only steps once every 16 clocks, so that path has ~15 clocks to settle and is only ever captured on a cpu_en tick. The static-timing engine didn’t know that. A set_multicycle_path exception on the paths into the CPU registers and the memory block RAMs turned the same physical design from -0.673 ns into +1.74 ns of positive slack. The lesson is pure computer architecture: a slow, enable-gated core on a fast clock is a multicycle design, and you have to say so.
The keyboard read “E0” as “70” — a reset that outlasted my patience
In the top-level testbench the arrow keys decoded to garbage: the first byte of every keypress, 0xE0, came out as 0x70the exact same bits, shifted right by one. The bare PS/2 decoder passed in isolation, so the module was fine. The culprit was timing: the board’s power-on reset counts down over 255 clocks, but my testbench only waited 40 before it started clocking in the frame. The keyboard was still held in reset when the start bit arrived, so it missed one falling edge and every subsequent bit landed one position off. A one-line fix (wait for POR to finish) — but a perfect reminder that on real hardware, reset is not instantaneous and off-by-one in time looks exactly like off-by-one in data.
Adding an ILA fought back three different ways
I wanted an Integrated Logic Analyzer on pc, instruction, outM and the keyboard so I could watch the CPU on real silicon. Three separate battles: (1) Vivado 2025.2 rejected the property C_CLK_INPUT_FREQ_HZ on the ILA core, which aborted insertion half-way and left a broken hub; (2) inserting the debug core after opt_design pruned my mark_debug taps (nothing was driving them yet) → “driverless nets” at placement; (3) the auto-created dbg_hub came up with an unconnected clock. The clean resolution was to insert the ILA before opt_design so the taps keep a load and the hub is generated with a proper clock, and to extend the same multicycle exception to the ILA’s capture registers. Both a plain and an ILA-instrumented bitstream now build and meet timing.
What it costs on the Artix-7

The whole computer — CPU, ROM, RAM, framebuffer, VGA, keyboard — barely dents the little xc7a35t. It is almost all memory; the logic is a rounding error.

ResourceUsedOf deviceNote
Slice LUTs2741.3 %the entire CPU + I/O logic
Slice registers1350.3 %A / D / PC + pipeline
Block RAM tiles28.557 %32K ROM + 16K RAM + 8K screen
Bonded IOB3432 %VGA(14) + PS/2(2) + clk/btn + 16 LEDs
Timing● WNS +1.74 ns @ 100 MHzall constraints met
Act 7 — Play it
A game, in machine code I wrote, on a computer I built

The payoff. A falling-blocks game: a 16×16 block drops one row per tick, you steer it left/right, it locks at the bottom and stacks, a new one spawns, and the board resets when the stack tops out. On the board it runs on the real CPU, drawn over VGA, driven by the keyboard. To write it I also built a small Hack assembler (in Python) that turns readable assembly into the 16-bit machine code the ROM boots from.

▶ This is not a re-implementation — it’s the real thing

The screen below is a faithful Hack CPU emulator running in your browser, executing the exact same 258-word ROM (tetris.hex) that is programmed onto the Basys3. Same bits, same fetch/decode/execute, same framebuffer scanned to pixels. Click the screen and use the arrow keys (or the buttons). What you see here is what lights up on the monitor.

HACK CPU · booting… ROM: tetris.hex · 258 words · PC 0
Click the screen, then steer with the ← → arrow keys. On the Basys3 the very same ROM runs the very same way — just on real gates, over a real VGA cable.

tetris.asm — the tick loop, in Hack assembly (assembled by tools/asm.py)

(FALLNOW)
    @bx
    D=M
    @100
    A=D+A          // &height[bx]
    D=M            // D = height[bx]
    @15
    D=A-D          // landing row = 15 - height[bx]
    @landing
    M=D
    @by
    D=M
    @landing
    D=M-D          // landing - by
    @LAND
    D;JLE          // by >= landing  -> lock the block
    @DO_DOWN       // else fall one row: erase, by++, base += 512, redraw
    D=A
    @after_move
    M=D
    @CALL_ERASE
    0;JMP

That @100   A=D+A is the whole trick to an array on a machine with no indexed addressing: height[] lives at RAM word 100, and the column index is just added to the base to form the pointer. The block’s screen address is maintained the same way — falling one row is base += 512 because a 16-pixel-tall block spans 16 screen rows of 32 words each.

Technology & Tools
LayerTechnologyRole
Course / methodNand2TetrisNAND-up architecture & the Hack ISA
BoardDigilent Basys3 (xc7a35t)Artix-7 FPGA, VGA port, USB-HID host
RTLVeriloggate-level chips + CPU/Memory/Computer + I/O
DisplayVGA 640x480@6025 MHz pixel pipe, 512x256 framebuffer
InputPS/2 (USB-HID)Set-2 scancode → Hack key codes
ToolsVivado 2025.2 + xsimsynth / impl / bitstream / simulation
DebugIntegrated Logic Analyzerlive capture of pc / instruction / outM
Toolchaintools/asm.py (Python)Hack assembler: .asm → ROM hex
Build it yourself

Everything — RTL, testbenches, the assembler, the game, the build/program scripts and prebuilt bitstreams — is in the repository. From a fresh clone:

assemble a program, simulate, then build & flash the Basys3

# 1. write & assemble a program to a ROM image
python3 tools/asm.py sim/games/tetris.asm -o sim/games/tetris.hex

# 2. simulate the whole computer (Vivado xsim)
bash build/run_sim.sh tb_tetris  sim/tb_tetris.v Computer.v CPU.v ...

# 3. synthesize, close timing, generate the bitstream (arg2: 1=insert ILA)
vivado -mode batch -source build/build.tcl -tclargs sim/games/tetris.hex 0

# 4. program the board over JTAG, then plug in a VGA monitor + USB keyboard
vivado -mode batch -source build/program.tcl

The instruction ROM is the full 32K Hack address space, so a larger high-level game (compiled through the Nand2Tetris Jack → VM → asm toolchain into a bigger .hack) drops straight into the same ROM slot — the hardware doesn’t change.