CHIP-8

A modern interpreter for the classic virtual machine

What is CHIP-8?

  • The Virtual Machine: Created by Joseph Weisbecker in 1978 to make game development portable across 8-bit systems.
  • Minimalist by Design: Operating with only 35 opcodes, thus quite easy to implement.
  • Iconic Constraints: Powers legendary titles like Pong, Breakout, and Space Invaders on a tiny 64x32 monochrome canvas.

Architecture

  • Memory: 4KB RAM (0x000 - 0xFFF)
  • Registers: 16 8-bit V0-VF
  • Stack: 16-level call stack
  • Display: 64x32 pixels
  • Input: 16-key hexadecimal keyboard

Instruction Set

Control & Jumps

00E0CLSClear screen
00EERETReturn from sub
1nnnJP nnnJump to nnn
2nnnCALL nnnCall sub at nnn
3xkkSE Vx, kkSkip if Vx == kk
4xkkSNE Vx, kkSkip if Vx != kk
5xy0SE Vx, VySkip if Vx == Vy
9xy0SNE Vx, VySkip if Vx != Vy
BnnnJP V0, nnnJump nnn + V0

Math & Logic

6xkkLD Vx, kkSet Vx = kk
7xkkADD Vx, kkVx = Vx + kk
8xy0LD Vx, VyVx = Vy
8xy1OR Vx, VyVx |= Vy
8xy2AND Vx, VyVx &= Vy
8xy3XOR Vx, VyVx ^= Vy
8xy4ADD Vx, VyVx += Vy (VF=C)
8xy5SUB Vx, VyVx -= Vy (VF=B)
8xy6SHR VxVx >> 1
8xyESHL VxVx << 1
CxkkRND Vx, kkVx = Rand & kk

I/O & Memory

AnnnLD I, nnnSet I = nnn
DxynDRW Vx,Vy,nDraw Sprite
Ex9ESKP VxSkip if Key Down
ExA1SKNP VxSkip if Key Up
Fx07LD Vx, DTVx = Delay Timer
Fx0ALD Vx, KWait for key
Fx1EADD I, VxI += Vx
Fx29LD F, VxSet I to Font
Fx33LD B, VxStore BCD at I
Fx55LD [I], VxReg Dump to RAM
Fx65LD Vx, [I]Load Reg from RAM

Execution Cycle

  • Fetch opcode from memory (PC)
  • Decode instruction
  • Execute operation
  • Update timers (DT, ST)
  • Repeat(~500–700 Hz, maybe arbitrary)

My Design

  • Goal: Build a complete CHIP-8 toolchain in Go
  • Two Core Components:
    • Emulator: executes CHIP-8 programs
    • Assembler: writes programs in CHIP-8 opcodes
  • Execution Flow:
    • Write program in assembly (or download ROM from the internet)
    • Assemble → binary opcodes
    • Load into emulator memory
    • Run through fetch-decode-execute cycle

Prerequisites & Installation

  • Go 1.25 or later
  • SDL2 development libraries
  • Clone, download dependencies, and build:
    git clone https://github.com/arpitchakladar/chip-8.git
    cd chip-8
    # Download Go module dependencies
    go mod download
    # Build the binary
    go build -o chip-8 ./cmd/chip-8

How to Use

  • Run a ROM:
    ./chip-8 run <path-to-rom>
  • Assemble files:
    ./chip-8 assemble <path-to-asm1> <path-to-asm2> ...
  • Specify output path:
    ./chip-8 assemble -o output.ch8 <file.asm>

The Emulator

  • CPU/Rendering loop written in Go
  • Opcode decoding using bit masking
  • Display buffer (64×32 framebuffer) with SDL2 to create the output display window
  • Keyboard input mapping (with SDL2)
  • Timers (ST and DT) running at 60Hz
  • Beep sounds playing as long a ST is not 0

The Emulator

internal/emulator/emulator.go
			// Package emulator provides the core CHIP-8 emulator functionality.
// It coordinates the CPU, memory, display, keyboard, and audio subsystems.
package emulator

import (
	"context"
	"fmt"
	"os"
	"sync"
	"time"

	"github.com/arpitchakladar/chip-8/internal/emulator/audio"
	"github.com/arpitchakladar/chip-8/internal/emulator/cpu"
	"github.com/arpitchakladar/chip-8/internal/emulator/display"
	"github.com/arpitchakladar/chip-8/internal/emulator/keyboard"
	"github.com/arpitchakladar/chip-8/internal/emulator/memory"
)

// ProgramStart is the memory address where CHIP-8 programs begin (0x200).
const ProgramStart = 0x200

// Emulator represents a complete CHIP-8 virtual machine.
// It coordinates the CPU, memory, display, keyboard, and audio subsystems.
type Emulator struct {
	CPU          *cpu.CPU
	Memory       *memory.Memory
	Display      display.Display
	Keyboard     keyboard.Keyboard
	Audio        audio.Audio
	ClockSpeed   uint32
	memoryLock   sync.Mutex
	running      bool
	runLock      sync.Mutex
	cancelRunner context.CancelFunc
}

// LoadROM loads a CHIP-8 ROM into memory starting at ProgramStart (0x200).
func (e *Emulator) LoadROM(romData []byte) error {
	for i, b := range romData {
		if err := e.Memory.Write(ProgramStart+uint16(i), b); err != nil {
			return err
		}
	}
	return nil
}

// Run starts the emulator main loop.
// It initializes the display and audio subsystems, then runs the CPU and display loops.
// The function blocks until the emulator is closed or an error occurs.
func (e *Emulator) Run(parentContext context.Context) error {
	e.runLock.Lock()
	if e.running {
		e.runLock.Unlock()
		return fmt.Errorf("emulator is already running")
	}
	e.running = true
	e.runLock.Unlock()

	if err := e.Display.Init(); err != nil {
		e.runLock.Lock()
		e.running = false
		e.runLock.Unlock()
		return fmt.Errorf("failed to init display: %w", err)
	}

	if err := e.Audio.Init(); err != nil {
		fmt.Printf("Warning: Audio failed to init: %v\n", err)
	}

	defer func() {
		if err := e.Display.Close(); err != nil {
			fmt.Fprintf(os.Stderr, "Error closing display: %v\n", err)
		}
		if err := e.Audio.Close(); err != nil {
			fmt.Fprintf(os.Stderr, "Error closing audio device: %v\n", err)
		}
	}()

	runEmulatorContext, cancelRunEmulatorContext := context.WithCancel(
		parentContext,
	)
	e.cancelRunner = cancelRunEmulatorContext
	errChan := make(chan error, 1)
	defer cancelRunEmulatorContext()

	go e.runCPU(runEmulatorContext, errChan)
	// Cannot be goroutine as SDL2 wants* to be on the main thread
	e.runDisplay(runEmulatorContext, errChan)

	err := <-errChan
	e.runLock.Lock()
	e.running = false
	e.runLock.Unlock()
	return err
}

// IsRunning returns true if the emulator is currently running.
func (e *Emulator) IsRunning() bool {
	e.runLock.Lock()
	isRunning := e.running
	e.runLock.Unlock()
	return isRunning
}

// runDisplay handles the display update loop at 60Hz.
// It polls for keyboard events, updates timers, and renders the display.
func (e *Emulator) runDisplay(
	runEmulatorContext context.Context,
	errChan chan<- error,
) {
	uiClock := time.NewTicker(time.Second / 60)
	defer uiClock.Stop()

	for {
		select {
		case <-runEmulatorContext.Done():
			return
		case <-uiClock.C:
			e.Keyboard.PollEvents()

			e.memoryLock.Lock()
			timerErr := e.updateTimers()
			displayErr := e.Display.Present()
			e.memoryLock.Unlock()

			if timerErr != nil {
				errChan <- timerErr
				return
			}
			if displayErr != nil {
				errChan <- displayErr
				return
			}
		}
	}
}

// runCPU runs the CPU execution loop at the configured ClockSpeed.
// It uses a fixed ticker at MaxTickRate and executes ClockSpeed/MaxTickRate
// instructions per tick to achieve the target clock speed.
func (e *Emulator) runCPU(
	runEmulatorContext context.Context,
	errChan chan<- error,
) {
	batchSize := max(int(e.ClockSpeed/MaxTickRate), 1)

	cpuClock := time.NewTicker(time.Second / MaxTickRate)
	defer cpuClock.Stop()

	for {
		select {
		case <-runEmulatorContext.Done():
			return
		case <-cpuClock.C:
			e.memoryLock.Lock()
			for range batchSize {
				err := e.tick()
				if err != nil {
					e.memoryLock.Unlock()
					errChan <- err
					return
				}
			}
			e.memoryLock.Unlock()
		}
	}
}

// tick performs one CPU fetch-decode-execute cycle.
func (e *Emulator) tick() error {
	hi, err := e.Memory.Read(e.CPU.ProgramCounter)
	if err != nil {
		return err
	}
	lo, err := e.Memory.Read(e.CPU.ProgramCounter + 1)
	if err != nil {
		return err
	}
	opcode := uint16(hi)<<8 | uint16(lo)

	e.CPU.ProgramCounter += 2

	return e.CPU.Execute(opcode, e.Memory, e.Display, e.Keyboard)
}

// updateTimers decrements the delay and sound timers at 60Hz.
func (e *Emulator) updateTimers() error {
	if e.CPU.SoundTimer > 0 {
		if err := e.Audio.Play(); err != nil {
			return err
		}
		e.CPU.SoundTimer--
	} else {
		if err := e.Audio.Pause(); err != nil {
			return err
		}
	}

	if e.CPU.DelayTimer > 0 {
		e.CPU.DelayTimer--
	}

	return nil
}

// Stops the runner (CPU and Display goroutine) threads.
func (e *Emulator) Destroy() {
	e.runLock.Lock()
	defer e.runLock.Unlock()
	e.running = false
	e.cancelRunner()
}





















		
The components

Just like a real machine, our emulator will have a CPU, Memory, Display, Keyboard and Audio components. Each performing the task inferred by their name.

CPU Goroutine

Running the cpu in a goroutine and display/timer on the main function for parallelism.

CPU Loop

Fetch-decode-execute cycle logic with batch processing for systems where the max tick frequency is less than the configured CPU clock speed.

The display/timer loop

Rendering and timer countdowns happen at 60Hz only, so there is a benefit of running them in parallel, as the 60Hz constraint won't throttle the CPU.

The CPU

internal/emulator/cpu/cpu.go
			// Package cpu provides CPU execution and opcode handling for the CHIP-8 emulator.
package cpu

// The CPU is responsible for identifying and running
// each opcode.
type CPU struct {
	// Registers are the 16 general-purpose 8-bit registers.
	// Historically referred to as V0 through VF.
	Registers [16]byte

	// IndexRegister stores memory addresses for use in operations.
	// Historically referred to as the 'I' register.
	IndexRegister uint16

	// ProgramCounter stores the memory address of the next instruction to be executed.
	ProgramCounter uint16

	// StackPointer points to the current top of the stack.
	StackPointer uint8

	// Stack is used to store the return addresses when subroutines are called.
	// It allows for up to 16 levels of nested function calls.
	Stack [16]uint16

	// DelayTimer is used for game events; it decrements at a rate of 60Hz.
	DelayTimer byte

	// SoundTimer decrements at 60Hz and triggers a buzz as long as the value is > 0.
	SoundTimer byte
}

// New initializes a CPU with the standard entry point for Chip-8 programs.
func New() *CPU {
	return new(CPU)
}





















		
General purpose registors

Only noteable difference with other CPU architectures is that VF acts both as general purpose registor as well as a carry/borrow flag for arithmetic operations.

Some more common registors

These are some of the special purpose registors that exist more or less in any architecture.

The unique registors

Now these registors are very much CHIP-8 centric (specially the Sound Timer).

The CPU

internal/emulator/cpu/opcodes.go
			package cpu

import (
	"math/rand"

	"github.com/arpitchakladar/chip-8/internal/emulator/display"
	"github.com/arpitchakladar/chip-8/internal/emulator/keyboard"
	"github.com/arpitchakladar/chip-8/internal/emulator/memory"
)

// Execute decodes and performs the operation specified by the 16-bit opcode.
// It handles all CHIP-8 instructions by decoding the opcode into its component parts
// and executing the appropriate logic.
//
// Opcode format (16 bits): F | X | Y | N
//   - F: 4-bit function code (for 8xyN instructions)
//   - X: 4-bit register index (Vy reference)
//   - Y: 4-bit register index (Vy reference)
//   - N: 4-bit constant (or nibble)
//
// Additional decoded values:
//   - nnn: 12-bit address (lower 12 bits of opcode)
//   - kk: 8-bit constant (lower 8 bits of opcode)
//
// Opcode groups:
//   - 0x0000: System and subroutine operations (CLS, RET)
//   - 0x1000: JP - Jump to address
//   - 0x2000: CALL - Call subroutine
//   - 0x3000: SE Vx, byte - Skip if equal
//   - 0x4000: SNE Vx, byte - Skip if not equal
//   - 0x5000: SE Vx, Vy - Skip if registers equal
//   - 0x6000: LD Vx, byte - Load constant
//   - 0x7000: ADD Vx, byte - Add constant
//   - 0x8000: ALU operations (OR, AND, XOR, SUB, SHR, SUBN, SHL)
//   - 0x9000: SNE Vx, Vy - Skip if registers not equal
//   - 0xA000: LD I, addr - Load address into I
//   - 0xB000: JP V0, addr - Jump with offset
//   - 0xC000: RND - Random number
//   - 0xD000: DRW - Draw sprite
//   - 0xE000: Keyboard operations (SKP, SKNP)
//   - 0xF000: Miscellaneous (timers, memory, BCD)
//
// Returns:
//   - nil: on successful execution
//   - *InvalidOpcodeError: if the opcode is not recognized
//   - *StackError: if stack overflow/underflow occurs
//   - *MemorySyncError: if memory read/write fails
type opcodeHandler func(*CPU, uint16, *memory.Memory, display.Display, keyboard.Keyboard, uint8, uint8, uint16, byte, byte) error

var opcodeHandlers = map[uint16]opcodeHandler{
	0x0000: handleGroup0,
	0x1000: handleGroup1,
	0x2000: handleGroup2,
	0x3000: handleGroup3,
	0x4000: handleGroup4,
	0x5000: handleGroup5,
	0x6000: handleGroup6,
	0x7000: handleGroup7,
	0x8000: handleGroup8,
	0x9000: handleGroup9,
	0xA000: handleGroupA,
	0xB000: handleGroupB,
	0xC000: handleGroupC,
	0xD000: handleGroupD,
	0xE000: handleGroupE,
	0xF000: handleGroupF,
}

func (c *CPU) Execute(
	opcode uint16,
	mem *memory.Memory,
	disp display.Display,
	keyb keyboard.Keyboard,
) error {
	x := uint8((opcode & 0x0F00) >> 8)
	y := uint8((opcode & 0x00F0) >> 4)
	nnn := opcode & 0x0FFF
	kk := byte(opcode & 0x00FF)
	n := byte(opcode & 0x000F)

	group := opcode & 0xF000
	handler, ok := opcodeHandlers[group]
	if !ok {
		return c.handleInvalidOpcode(opcode)
	}
	return handler(c, opcode, mem, disp, keyb, x, y, nnn, kk, n)
}

func handleGroup0(
	c *CPU,
	opcode uint16,
	_ *memory.Memory,
	disp display.Display,
	_ keyboard.Keyboard,
	_, _ uint8,
	_ uint16,
	_ byte,
	_ byte,
) error {
	return c.handle0xxx(opcode, disp)
}

func handleGroup1(
	c *CPU,
	_ uint16,
	_ *memory.Memory,
	_ display.Display,
	_ keyboard.Keyboard,
	_, _ uint8,
	nnn uint16,
	_ byte,
	_ byte,
) error {
	c.ProgramCounter = nnn
	return nil
}

func handleGroup2(
	c *CPU,
	_ uint16,
	_ *memory.Memory,
	_ display.Display,
	_ keyboard.Keyboard,
	_, _ uint8,
	nnn uint16,
	_ byte,
	_ byte,
) error {
	return c.handle2xxx(nnn)
}

func handleGroup3(
	c *CPU,
	_ uint16,
	_ *memory.Memory,
	_ display.Display,
	_ keyboard.Keyboard,
	x uint8,
	_ uint8,
	_ uint16,
	kk byte,
	_ byte,
) error {
	if c.Registers[x] == kk {
		c.ProgramCounter += 2
	}
	return nil
}

func handleGroup4(
	c *CPU,
	_ uint16,
	_ *memory.Memory,
	_ display.Display,
	_ keyboard.Keyboard,
	x uint8,
	_ uint8,
	_ uint16,
	kk byte,
	_ byte,
) error {
	if c.Registers[x] != kk {
		c.ProgramCounter += 2
	}
	return nil
}

func handleGroup5(
	c *CPU,
	_ uint16,
	_ *memory.Memory,
	_ display.Display,
	_ keyboard.Keyboard,
	x, y uint8,
	_ uint16,
	_ byte,
	_ byte,
) error {
	if c.Registers[x] == c.Registers[y] {
		c.ProgramCounter += 2
	}
	return nil
}

func handleGroup6(
	c *CPU,
	_ uint16,
	_ *memory.Memory,
	_ display.Display,
	_ keyboard.Keyboard,
	x uint8,
	_ uint8,
	_ uint16,
	kk byte,
	_ byte,
) error {
	c.Registers[x] = kk
	return nil
}

func handleGroup7(
	c *CPU,
	_ uint16,
	_ *memory.Memory,
	_ display.Display,
	_ keyboard.Keyboard,
	x uint8,
	_ uint8,
	_ uint16,
	kk byte,
	_ byte,
) error {
	c.Registers[x] += kk
	return nil
}

func handleGroup8(
	c *CPU,
	_ uint16,
	_ *memory.Memory,
	_ display.Display,
	_ keyboard.Keyboard,
	x, y uint8,
	_ uint16,
	_ byte,
	n byte,
) error {
	return c.handle8xxx(x, y, n)
}

func handleGroup9(
	c *CPU,
	_ uint16,
	_ *memory.Memory,
	_ display.Display,
	_ keyboard.Keyboard,
	x, y uint8,
	_ uint16,
	_ byte,
	_ byte,
) error {
	if c.Registers[x] != c.Registers[y] {
		c.ProgramCounter += 2
	}
	return nil
}

func handleGroupA(
	c *CPU,
	_ uint16,
	_ *memory.Memory,
	_ display.Display,
	_ keyboard.Keyboard,
	_, _ uint8,
	nnn uint16,
	_ byte,
	_ byte,
) error {
	c.IndexRegister = nnn
	return nil
}

func handleGroupB(
	c *CPU,
	_ uint16,
	_ *memory.Memory,
	_ display.Display,
	_ keyboard.Keyboard,
	_, _ uint8,
	nnn uint16,
	_ byte,
	_ byte,
) error {
	c.ProgramCounter = nnn + uint16(c.Registers[0])
	return nil
}

func handleGroupC(
	c *CPU,
	_ uint16,
	_ *memory.Memory,
	_ display.Display,
	_ keyboard.Keyboard,
	x uint8,
	_ uint8,
	_ uint16,
	kk byte,
	_ byte,
) error {
	c.Registers[x] = byte(rand.Intn(256)) & kk
	return nil
}

func handleGroupD(
	c *CPU,
	_ uint16,
	mem *memory.Memory,
	disp display.Display,
	_ keyboard.Keyboard,
	x, y uint8,
	_ uint16,
	_ byte,
	n byte,
) error {
	return c.handleDxxx(x, y, n, mem, disp)
}

func handleGroupE(
	c *CPU,
	_ uint16,
	_ *memory.Memory,
	_ display.Display,
	keyb keyboard.Keyboard,
	x uint8,
	_ uint8,
	_ uint16,
	kk byte,
	_ byte,
) error {
	return c.handleExxx(x, kk, keyb)
}

func handleGroupF(
	c *CPU,
	_ uint16,
	mem *memory.Memory,
	_ display.Display,
	keyb keyboard.Keyboard,
	x uint8,
	_ uint8,
	_ uint16,
	kk byte,
	_ byte,
) error {
	return c.handleFxxx(x, kk, mem, keyb)
}

func (c *CPU) handleInvalidOpcode(opcode uint16) error {
	return &InvalidOpcodeError{
		Opcode:         opcode,
		ProgramCounter: c.ProgramCounter - 2,
	}
}

func (c *CPU) handle0xxx(opcode uint16, disp display.Display) error {
	switch opcode {
	case 0x00E0:
		disp.Clear()
	case 0x00EE:
		if c.StackPointer == 0 {
			return &StackError{
				IsOverflow:     false,
				ProgramCounter: c.ProgramCounter - 2,
			}
		}
		c.StackPointer--
		c.ProgramCounter = c.Stack[c.StackPointer]
	default:
		return &InvalidOpcodeError{
			Opcode:         opcode,
			ProgramCounter: c.ProgramCounter - 2,
		}
	}
	return nil
}

func (c *CPU) handle2xxx(nnn uint16) error {
	if c.StackPointer >= 16 {
		return &StackError{
			IsOverflow:     true,
			ProgramCounter: c.ProgramCounter - 2,
		}
	}
	c.Stack[c.StackPointer] = c.ProgramCounter
	c.StackPointer++
	c.ProgramCounter = nnn
	return nil
}

func (c *CPU) handle8xxx(x, y uint8, n byte) error {
	switch n {
	case 0x0:
		c.Registers[x] = c.Registers[y]
	case 0x1:
		c.Registers[x] |= c.Registers[y]
	case 0x2:
		c.Registers[x] &= c.Registers[y]
	case 0x3:
		c.Registers[x] ^= c.Registers[y]
	case 0x4:
		return c.handleAddCarry(x, y)
	case 0x5:
		return c.handleSub(x, y, true)
	case 0x6:
		return c.handleShiftRight(x)
	case 0x7:
		return c.handleSubReverse(x, y)
	case 0xE:
		return c.handleShiftLeft(x)
	}
	return nil
}

func (c *CPU) handleAddCarry(x, y uint8) error {
	sum := uint16(c.Registers[x]) + uint16(c.Registers[y])
	c.Registers[0xF] = 0
	if sum > 255 {
		c.Registers[0xF] = 1
	}
	c.Registers[x] = byte(sum & 0xFF)
	return nil
}

func (c *CPU) handleSub(x, y uint8, normal bool) error {
	c.Registers[0xF] = 1
	if c.Registers[x] < c.Registers[y] {
		c.Registers[0xF] = 0
	}
	if normal {
		c.Registers[x] -= c.Registers[y]
	} else {
		c.Registers[x] = c.Registers[y] - c.Registers[x]
	}
	return nil
}

func (c *CPU) handleSubReverse(x, y uint8) error {
	c.Registers[0xF] = 1
	if c.Registers[y] < c.Registers[x] {
		c.Registers[0xF] = 0
	}
	c.Registers[x] = c.Registers[y] - c.Registers[x]
	return nil
}

func (c *CPU) handleShiftRight(x uint8) error {
	c.Registers[0xF] = c.Registers[x] & 0x1
	c.Registers[x] >>= 1
	return nil
}

func (c *CPU) handleShiftLeft(x uint8) error {
	c.Registers[0xF] = (c.Registers[x] & 0x80) >> 7
	c.Registers[x] <<= 1
	return nil
}

func (c *CPU) handleDxxx(
	x, y uint8,
	n byte,
	mem *memory.Memory,
	disp display.Display,
) error {
	c.Registers[0xF] = 0
	for row := range uint16(n) {
		spriteByte, err := mem.Read(c.IndexRegister + row)
		if err != nil {
			return &MemorySyncError{
				Opcode: 0xD000 | (uint16(x) << 8) | (uint16(y) << 4) | uint16(
					n,
				),
				ProgramCounter: c.ProgramCounter - 2,
				Child:          err,
			}
		}
		for col := range uint16(8) {
			if (spriteByte & (0x80 >> col)) != 0 {
				posX := (c.Registers[x] + uint8(col)) % 64
				posY := (c.Registers[y] + uint8(row)) % 32
				collision, _ := disp.SetPixel(posX, posY)
				if collision {
					c.Registers[0xF] = 1
				}
			}
		}
	}
	return nil
}

func (c *CPU) handleExxx(x uint8, kk byte, keyb keyboard.Keyboard) error {
	switch kk {
	case 0x9E:
		if keyb.IsKeyPressed(c.Registers[x]) {
			c.ProgramCounter += 2
		}
	case 0xA1:
		if !keyb.IsKeyPressed(c.Registers[x]) {
			c.ProgramCounter += 2
		}
	}
	return nil
}

func (c *CPU) handleFxxx(
	x uint8,
	kk byte,
	mem *memory.Memory,
	keyb keyboard.Keyboard,
) error {
	switch kk {
	case 0x07:
		c.Registers[x] = c.DelayTimer
	case 0x0A:
		return c.handleKeyWait(x, keyb)
	case 0x15:
		c.DelayTimer = c.Registers[x]
	case 0x18:
		c.SoundTimer = c.Registers[x]
	case 0x1E:
		c.IndexRegister += uint16(c.Registers[x])
	case 0x29:
		c.IndexRegister = uint16(c.Registers[x]) * 5
	case 0x33:
		return c.handleBCD(x, mem)
	case 0x55:
		return c.handleStoreRegs(x, mem)
	case 0x65:
		return c.handleLoadRegs(x, mem)
	}
	return nil
}

func (c *CPU) handleKeyWait(x uint8, keyb keyboard.Keyboard) error {
	if key, pressed := keyb.AnyKeyPressed(); pressed {
		c.Registers[x] = key
	} else {
		c.ProgramCounter -= 2
	}
	return nil
}

func (c *CPU) handleBCD(x uint8, mem *memory.Memory) error {
	val := c.Registers[x]
	opcode := 0x3000 | (uint16(x) << 8)
	if err := mem.Write(c.IndexRegister, val/100); err != nil {
		return &MemorySyncError{
			Opcode:         opcode,
			ProgramCounter: c.ProgramCounter - 2,
			Child:          err,
		}
	}
	if err := mem.Write(c.IndexRegister+1, (val/10)%10); err != nil {
		return &MemorySyncError{
			Opcode:         opcode,
			ProgramCounter: c.ProgramCounter - 2,
			Child:          err,
		}
	}
	if err := mem.Write(c.IndexRegister+2, val%10); err != nil {
		return &MemorySyncError{
			Opcode:         opcode,
			ProgramCounter: c.ProgramCounter - 2,
			Child:          err,
		}
	}
	return nil
}

func (c *CPU) handleStoreRegs(x uint8, mem *memory.Memory) error {
	opcode := 0xF000 | (uint16(x) << 8) | 0x55
	for i := uint8(0); i <= x; i++ {
		if err := mem.Write(c.IndexRegister+uint16(i), c.Registers[i]); err != nil {
			return &MemorySyncError{
				Opcode:         opcode,
				ProgramCounter: c.ProgramCounter - 2,
				Child:          err,
			}
		}
	}
	return nil
}

func (c *CPU) handleLoadRegs(x uint8, mem *memory.Memory) error {
	opcode := 0xF000 | (uint16(x) << 8) | 0x65
	for i := uint8(0); i <= x; i++ {
		val, err := mem.Read(c.IndexRegister + uint16(i))
		if err != nil {
			return &MemorySyncError{
				Opcode:         opcode,
				ProgramCounter: c.ProgramCounter - 2,
				Child:          err,
			}
		}
		c.Registers[i] = val
	}
	return nil
}





















		
Opcode extraction

Using bit masking to extract register indices (x, y), 12-bit address (nnn), 8-bit constant (kk), and 4-bit nibble (n).

Finding the group

Find the group that the opcode belongs to and dispatch the appropriate handler.

The big switch

The CPU instruction execution is just one large switch statement that decides what handler to run. Here however we are using a map for cleanliness.

The Memory

internal/emulator/memory/memory.go
			// Package memory provides memory management for the CHIP-8 emulator.
// Handles 4KB of RAM with read/write operations and font set loading.
package memory

// Memory provides the read/write interface for CHIP-8 system memory.
// It manages 4096 bytes of RAM with font set loading and write protection.

const (
	// Size is the total memory size in bytes (4KB, standard CHIP-8).
	Size = 4096
)

// Memory represents the 4096 bytes of RAM in a CHIP-8 system.
type Memory struct {
	// RAM is the physical storage for all memory operations.
	// Memory layout:
	//   - 0x000-0x1FF (512 bytes): Reserved for font set and system use
	//   - 0x200-0xFFF (3840 bytes): Program ROM and work RAM
	RAM [Size]byte
}

// New creates a blank 4KB Memory instance with all bytes initialized to zero.
// The font set must be loaded using LoadFontSet() before the emulator can run.
func New() *Memory {
	return new(Memory)
}

// Reset clears all 4096 bytes of memory to zero.
// Note: After resetting, you must call LoadFontSet() to restore the font data,
// otherwise the display will not render characters correctly.
func (m *Memory) Reset() {
	m.RAM = [Size]byte{}
}

// Read returns the byte stored at the given memory address.
//
// Parameters:
//   - address: 16-bit memory address (0x000 to 0xFFF)
//
// Returns:
//   - byte: the value stored at the address
//   - error: *BoundsError if address is out of range (>= 4096)
func (m *Memory) Read(address uint16) (byte, error) {
	if address >= Size {
		return 0, &BoundsError{Address: address, Max: 4095}
	}
	return m.RAM[address], nil
}

// Write stores a byte at the given memory address.
//
// Parameters:
//   - address: 16-bit memory address (0x000 to 0xFFF)
//   - value: the byte to write
//
// Returns:
//   - nil on success
//   - *BoundsError if address is out of range (>= 4096)
//   - *WriteProtectedError if attempting to write to font area (0x000-0x1FF)
//
// The first 512 bytes (0x000-0x1FF) are write-protected because they
// contain the font set. Standard programs should not write to this area.
func (m *Memory) Write(address uint16, value byte) error {
	// Check physical bounds
	if address >= Size {
		return &BoundsError{Address: address, Max: 4095}
	}

	// Check for protected area (font set location)
	// Standard ROMs should never overwrite the font set
	if address < 0x200 {
		return &WriteProtectedError{Address: address}
	}

	m.RAM[address] = value
	return nil
}

// LoadFontSet populates the first 80 bytes of memory (0x000-0x04F)
// with the standard 4x5 pixel font set for hexadecimal characters 0-F.
//
// Each character is encoded as 5 bytes, with each byte representing
// a row of 8 pixels (only the lower 4 bits are used):
//
//	Byte 0: Top row (bits 4-7 used)
//	Byte 1: Second row
//	Byte 2: Third row
//	Byte 3: Fourth row
//	Byte 4: Bottom row
//
// Font data is indexed by character: address = character * 5
// e.g., character '0' at 0x000, '1' at 0x005, 'A' at 0x0050, etc.
func (m *Memory) LoadFontSet() {
	fontSet := []byte{
		0xF0, 0x90, 0x90, 0x90, 0xF0, // 0
		0x20, 0x60, 0x20, 0x20, 0x70, // 1
		0xF0, 0x10, 0xF0, 0x80, 0xF0, // 2
		0xF0, 0x10, 0xF0, 0x10, 0xF0, // 3
		0x90, 0x90, 0xF0, 0x10, 0x10, // 4
		0xF0, 0x80, 0xF0, 0x10, 0xF0, // 5
		0xF0, 0x80, 0xF0, 0x90, 0xF0, // 6
		0xF0, 0x10, 0x20, 0x40, 0x40, // 7
		0xF0, 0x90, 0xF0, 0x90, 0xF0, // 8
		0xF0, 0x90, 0xF0, 0x10, 0xF0, // 9
		0xF0, 0x90, 0xF0, 0x90, 0x90, // A
		0xE0, 0x90, 0xE0, 0x90, 0xE0, // B
		0xF0, 0x80, 0x80, 0x80, 0xF0, // C
		0xE0, 0x90, 0x90, 0x90, 0xE0, // D
		0xF0, 0x80, 0xF0, 0x80, 0xF0, // E
		0xF0, 0x80, 0xF0, 0x80, 0x80, // F
	}

	// Copy the 80 bytes of font data into the start of RAM
	copy(m.RAM[0:80], fontSet)
}





















		
It's average

Very little RAM fixed at 4kb. Providing the biggest constraint to developers only second to the display size.

Just an array

The memory is just a BIG (yes) byte array.

Loading fonts

CHIP-8 has a inbuilt method to display characters for hexadecimal (0-F).

The Display Interface

internal/emulator/display/interface.go
			package display

// Display represents the display subsystem for the CHIP-8 emulator.
// Implement this interface to provide display output for different platforms
// (e.g., SDL2, JavaScript/Canvas, etc.).
type Display interface {
	// Init initializes the display subsystem and creates any necessary windows or surfaces.
	// Returns an error if initialization fails.
	Init() error

	// Clear clears the entire display buffer to all pixels off.
	// clears the display buffer without affecting the screen.
	Clear()

	// SetPixel toggles a pixel at the specified coordinates using XOR mode.
	// If a pixel is already on, drawing it again turns it off.
	// Coordinates are wrapped to stay within bounds (0-63 for x, 0-31 for y).
	// Returns true if the pixel was already on (collision), false otherwise.
	// Returns an error if coordinates are invalid.
	SetPixel(x, y uint8) (bool, error)

	// Present renders the current display buffer to the screen.
	// This should be called at 60Hz.
	Present() error

	// Close releases display resources.
	// Should be safe to call multiple times.
	Close() error
}





















		
The Display Interface

A go interface that defines how the emulator talks to different display implementations.

Init

Initializes the display. This is where the window is created (for SDL2).

Init

Clears the display. Sets all pixels to black.

SetPixel

Sets a pixel at x,y using XOR mode (toggles). Returns true if there was pixel collision.

Present

Renders the display buffer to screen. Called at 60Hz.

Close

Releases display resources.

SDL2 Display

internal/emulator/display/sdl.go
			//go:build !wasm || !js

// Package display provides an SDL2-compatible display implementation for the CHIP-8 emulator.
package display

// SDLDisplay manages the display output for the CHIP-8 emulator.
// It maintains a pixel buffer and renders it to an SDL2 window.

import (
	"fmt"

	"github.com/veandco/go-sdl2/sdl"
)

const (
	// Scale is the scaling factor for rendering pixels to screen.
	// Each CHIP-8 pixel will be rendered as Scale x Scale on screen.
	Scale = 15
)

// SDLDisplay maintains the CHIP-8 display state and SDL2 rendering resources.
type SDLDisplay struct {
	// buffer is the display buffer for pixel storage.
	buffer *DisplayBuffer
	// window is the SDL2 window handle.
	window *sdl.Window
	// renderer is the SDL2 renderer for drawing to the window.
	renderer *sdl.Renderer
}

// New creates a new, cleared SDLDisplay instance.
// The pixel buffer is initialized to all zeros (black).
// Call Init() before use to create the SDL window.
func WithSDL() *SDLDisplay {
	return &SDLDisplay{buffer: NewDisplayBuffer()}
}

// Init initializes the SDL2 subsystem and creates the window and renderer.
// It initializes all SDL2 subsystems, creates a centered window at 64*Scale x 32*Scale
// pixels, and creates an accelerated renderer for the window.
//
// Returns:
//   - nil on success
//   - *SDLError if SDL initialization, window creation, or renderer creation fails
func (d *SDLDisplay) Init() error {
	// Initialize all SDL2 subsystems
	if err := sdl.Init(uint32(sdl.INIT_EVERYTHING)); err != nil {
		return &SDLError{Subsystem: "Initialization", Child: err}
	}

	// Create the emulator window
	window, err := sdl.CreateWindow(
		"Chip-8 Emulator",
		int32(sdl.WINDOWPOS_CENTERED), int32(sdl.WINDOWPOS_CENTERED),
		int32(Width*Scale), int32(Height*Scale),
		uint32(sdl.WINDOW_SHOWN),
	)
	if err != nil {
		return &SDLError{Subsystem: "Window Creation", Child: err}
	}

	// Create the renderer (hardware accelerated preferred)
	dr, err := sdl.CreateRenderer(window, -1, uint32(sdl.RENDERER_ACCELERATED))
	if err != nil {
		return &SDLError{Subsystem: "Renderer Creation", Child: err}
	}

	d.window = window
	d.renderer = dr
	return nil
}

// Clears the entire pixel buffer to black (all zeros).
// This is equivalent to turning off all pixels.
// It is called by the CLS (0x00E0) opcode to clear the display.
// Note: This only clears the in-memory buffer, not the actual screen.
func (d *SDLDisplay) Clear() {
	d.buffer.Clear()
}

// SetPixel toggles a pixel at the specified coordinates using XOR mode.
// CHIP-8 uses XOR drawing: if a pixel is already on, drawing it again turns it off.
//
// Coordinates are wrapped to stay within bounds (standard CHIP-8 behavior):
//   - x wraps to 0-63 (64 pixels wide)
//   - y wraps to 0-31 (32 pixels tall)
//
// Parameters:
//   - x: X coordinate (0-63)
//   - y: Y coordinate (0-31)
//
// Returns:
//   - bool: true if the pixel was already on (collision), false otherwise
//   - error: *CoordinateError if coordinates are out of bounds (before wrapping),
//     or nil if coordinates are valid (even after wrapping)
func (d *SDLDisplay) SetPixel(x, y uint8) (bool, error) {
	return d.buffer.SetPixel(x, y)
}

// Present renders the current pixel buffer to the SDL window.
// It clears the screen to black, then draws all pixels that are set (value 1)
// as white rectangles. Each pixel is scaled according to the Scale constant.
//
// The rendering order is:
//  1. Clear screen to black
//  2. Set draw color to white
//  3. Draw all "on" pixels as rectangles
//  4. Present (flip) the screen
//
// Returns:
//   - nil on success
//   - *SDLError if renderer is not initialized or drawing fails
func (d *SDLDisplay) Present() error {
	if d.renderer == nil {
		return &SDLError{
			Subsystem: "Renderer",
			Child:     fmt.Errorf("renderer not initialized"),
		}
	}

	// Clear screen to black
	if err := d.renderer.SetDrawColor(0, 0, 0, 255); err != nil {
		return &SDLError{Subsystem: "SetDrawColor (Background)", Child: err}
	}
	if err := d.renderer.Clear(); err != nil {
		return &SDLError{Subsystem: "Clear", Child: err}
	}

	// Set pixel color to white
	if err := d.renderer.SetDrawColor(255, 255, 255, 255); err != nil {
		return &SDLError{Subsystem: "SetDrawColor (Pixel)", Child: err}
	}

	// Draw each "on" pixel as a scaled rectangle
	for i, val := range d.buffer.Pixels {
		if val == 1 {
			// Calculate pixel position
			x := int32(i % Width)
			y := int32(i / Width)

			// Create scaled rectangle for pixel
			rect := sdl.Rect{
				X: x * Scale,
				Y: y * Scale,
				W: Scale,
				H: Scale,
			}

			if err := d.renderer.FillRect(&rect); err != nil {
				return &SDLError{Subsystem: "FillRect", Child: err}
			}
		}
	}

	// Present the rendered frame to the screen
	d.renderer.Present()

	return nil
}

// Close releases all display resources.
// It destroys the renderer first, then the window, and finally calls
// sdl.Quit(). If either destroy operation fails, the error is returned
// (preferring the renderer error).
//
// This function is safe to call multiple times (subsequent calls will be no-ops).
//
// Returns:
//   - nil on success
//   - *SDLError containing the last error encountered during cleanup
func (d *SDLDisplay) Close() error {
	lastErr := error(nil)

	// Destroy renderer first
	if d.renderer != nil {
		if err := d.renderer.Destroy(); err != nil {
			lastErr = &SDLError{Subsystem: "Renderer Destruction", Child: err}
		}
	}

	// Then destroy window
	if d.window != nil {
		if err := d.window.Destroy(); err != nil {
			lastErr = &SDLError{Subsystem: "Window Destruction", Child: err}
		}
	}

	// Clean up SDL subsystems
	sdl.Quit()

	return lastErr
}





















		
SDLDisplay struct

Holds the pixel buffer, SDL window, and renderer.

WithSDL constructor

Creates a new SDLDisplay with an initialized empty buffer.

Init creates window

Initializes SDL2, creates a centered window at 64*15 x 32*15 pixels.

Clear

Clears the pixel buffer to all zeros (black).

SetPixel

Delegates to the buffer for XOR drawing with collision detection.

Present renders to screen

Clears screen, draws all 'on' pixels as white rectangles, then Present() to flip.

Close cleanup

Destroys renderer, window, and calls sdl.Quit(). Safe to call multiple times.

The Keyboard Interface

internal/emulator/keyboard/interface.go
			// Package keyboard provides keyboard input implementations for the CHIP-8 emulator.
// Implement the Keyboard interface to provide input handling for different platforms.
package keyboard

// Keyboard represents the keyboard input subsystem for the CHIP-8 emulator.
// CHIP-8 has 16 keys (0-F). Implement this interface to provide input handling
// for different platforms (e.g., SDL2, JavaScript, etc.).
type Keyboard interface {
	// IsKeyPressed checks if a specific CHIP-8 key is currently pressed.
	// Key should be 0-15. Returns true if pressed, false otherwise.
	IsKeyPressed(key byte) bool

	// AnyKeyPressed checks if any CHIP-8 key is currently pressed.
	// Returns the key index (0-15) and true if any key is pressed,
	// or 0 and false if no keys are pressed.
	AnyKeyPressed() (byte, bool)

	// SetKey sets the pressed state of a CHIP-8 key.
	// Key should be 0-15.
	SetKey(key byte, pressed bool)

	// PollEvents processes platform-specific input events.
	// This should be called periodically (e.g., at 60Hz).
	// For SDL: polls and processes SDL keyboard events.
	// For Web: can be a no-op if using event-driven updates.
	PollEvents()
}





















		
The Keyboard Interface

A go interface for handling input. CHIP-8 has 16 keys (0-F).

IsKeyPressed

Check if a specific key (0-15) is currently pressed.

AnyKeyPressed

Returns the first pressed key. Used by LD Vx, K instruction for waiting on key input.

SetKey

Sets the state of a key (pressed/release).

PollEvents

Processes input events. Called at 60Hz.

SDL2 Keyboard

internal/emulator/keyboard/sdl.go
			//go:build !wasm || !js

// Package keyboard provides an SDL2-compatible keyboard implementation for the CHIP-8 emulator.
package keyboard

// SDLKeyboard tracks the state of the 16 CHIP-8 keys.
// It maintains a map of which keys are currently pressed and provides
// methods for checking key state, used by CPU opcodes for input handling.

import "github.com/veandco/go-sdl2/sdl"

// SDLKeyboard tracks the state of the 16 CHIP-8 keys.
type SDLKeyboard struct {
	// Keys stores the pressed state of each CHIP-8 key.
	// Index 0-15 corresponds to CHIP-8 keys 0x0-0xF.
	// true = pressed, false = released
	Keys [16]bool
}

// New creates a new SDLKeyboard instance with all keys initialized to released.
func WithSDL() *SDLKeyboard {
	return new(SDLKeyboard)
}

// IsKeyPressed checks if a specific CHIP-8 key is currently pressed.
// It is used by the SKP (0xEx9E) and SKNP (0xExA1) opcodes.
//
// Parameters:
//   - key: the CHIP-8 key index (0-15, corresponds to Vx register values)
//
// Returns:
//   - true if the key is currently pressed
//   - false if the key is released OR if key is out of range (>15)
func (kb *SDLKeyboard) IsKeyPressed(key byte) bool {
	return key <= 15 && kb.Keys[key]
}

// AnyKeyPressed checks if any CHIP-8 key is currently pressed.
// It is used by the LD Vx, K (0xFx0A) opcode to wait for key input.
//
// When no key is pressed, the CPU uses this to implement a blocking wait:
// it repeatedly executes this instruction until a key is pressed.
//
// Returns:
//   - byte: the key index (0-15) of the first pressed key found
//   - bool: true if any key is pressed, false if no keys are pressed
func (kb *SDLKeyboard) AnyKeyPressed() (byte, bool) {
	for i, isPressed := range kb.Keys {
		if isPressed {
			return byte(i), true
		}
	}
	return 0, false
}

// SetKey sets the pressed state of a CHIP-8 key.
func (kb *SDLKeyboard) SetKey(key byte, pressed bool) {
	if key <= 15 {
		kb.Keys[key] = pressed
	}
}

// PollEvents processes SDL keyboard events.
// Call this at 60Hz to update the keyboard state.
func (kb *SDLKeyboard) PollEvents() {
	for event := sdl.PollEvent(); event != nil; event = sdl.PollEvent() {
		if kbdEvent, ok := event.(*sdl.KeyboardEvent); ok {
			kb.HandleKeyboard(kbdEvent)
		}
	}
}

// HandleKeyboard updates the keyboard state based on an SDL keyboard event.
// It maps PC keyboard keys to CHIP-8 hex keys (0-F) and tracks press/release state.
//
// The key mapping follows the standard CHIP-8 layout:
//
//	    PC Key  | CHIP-8
//		--------|--------
//		1 2 3 4 | 1 2 3 C
//		q w e r | 4 5 6 D
//		a s d f | 7 8 9 E
//		z x c v | A 0 B F
//
// Parameters:
//   - event: pointer to an SDL KeyboardEvent (key press or release)
func (kb *SDLKeyboard) HandleKeyboard(event *sdl.KeyboardEvent) {
	keyCode := event.Keysym.Sym
	// Determine if this is a key press (true) or release (false)
	isPressed := event.Type == sdl.KEYDOWN

	// Map PC keyboard keys to CHIP-8 key indices
	mapping := map[sdl.Keycode]byte{
		sdl.Keycode(sdl.K_1): 0x1, sdl.Keycode(sdl.K_2): 0x2, sdl.Keycode(sdl.K_3): 0x3, sdl.Keycode(sdl.K_4): 0xC,
		sdl.Keycode(sdl.K_q): 0x4, sdl.Keycode(sdl.K_w): 0x5, sdl.Keycode(sdl.K_e): 0x6, sdl.Keycode(sdl.K_r): 0xD,
		sdl.Keycode(sdl.K_a): 0x7, sdl.Keycode(sdl.K_s): 0x8, sdl.Keycode(sdl.K_d): 0x9, sdl.Keycode(sdl.K_f): 0xE,
		sdl.Keycode(sdl.K_z): 0xA, sdl.Keycode(sdl.K_x): 0x0, sdl.Keycode(sdl.K_c): 0xB, sdl.Keycode(sdl.K_v): 0xF,
	}

	// Update the key state if the PC key maps to a CHIP-8 key
	if chipKey, ok := mapping[keyCode]; ok {
		kb.Keys[chipKey] = isPressed
	}
}





















		
SDLKeyboard struct

Holds an array of 16 booleans representing the pressed state of CHIP-8 keys 0-F.

WithSDL constructor

Creates a new SDLKeyboard with all keys initialized to released.

IsKeyPressed

Returns true if the specified key (0-15) is currently pressed.

AnyKeyPressed

Returns the first pressed key. Used by LD Vx, K for blocking key wait.

SetKey

Sets the state of a key. Validates key is 0-15.

PollEvents

Polls SDL for keyboard events and calls HandleKeyboard for each.

HandleKeyboard key mapping

Maps PC keys (1,2,3,4,Q,W,E,R,...) to CHIP-8 keys (1,2,3,C,4,5,6,D,...) and updates state.

The Audio Interface

internal/emulator/audio/interface.go
			// Package audio provides audio output implementations for the CHIP-8 emulator.
// Implement the Audio interface to provide sound for different platforms.
package audio

// Audio represents the audio subsystem for the CHIP-8 emulator.
// Implement this interface to provide audio output for different platforms
// (e.g., SDL2, JavaScript/WebAudio, etc.).
type Audio interface {
	// Init initializes the audio subsystem.
	// Returns an error if initialization fails.
	Init() error

	// Play starts/resumes audio playback.
	Play() error

	// Pause stops audio playback.
	Pause() error

	// Close releases audio resources.
	// Should be safe to call multiple times.
	Close() error
}





















		
The Audio Interface

A go interface for audio output. CHIP-8 has a simple beep sound.

Init

Initializes the audio subsystem.

Play/Pause

Start or stop the beep. Called based on the sound timer.

Close

Releases audio resources.

SDL2 Audio

internal/emulator/audio/sdl.go
			//go:build !wasm || !js

package audio

import (
	"unsafe"

	"github.com/veandco/go-sdl2/sdl"
)

const (
	// SampleRate is the audio sample rate in Hz.
	SampleRate = 44100
	// Frequency is the beep frequency in Hz (440Hz = standard A4 pitch).
	Frequency = 440.0
)

// SDLAudio manages sound output for the CHIP-8 emulator.
// It generates square wave beeps using SDL2 audio devices.
type SDLAudio struct {
	// Device is the SDL audio device ID used for sound output.
	Device sdl.AudioDeviceID
}

// New creates a new SDLAudio instance with an uninitialized audio device.
// Call Init() before use to open the audio device.
func WithSDL() *SDLAudio {
	return new(SDLAudio)
}

// Init opens the default audio device and configures it for sound output.
// It sets up mono audio at 44.1kHz with 16-bit signed samples and 2048 sample buffer.
// Returns an error if the audio device cannot be opened.
//
// Note: On systems without audio hardware, this may return an error but the
// emulator should continue to run without sound.
func (a *SDLAudio) Init() error {
	spec := &sdl.AudioSpec{
		Freq:     SampleRate,
		Format:   sdl.AUDIO_S16SYS,
		Channels: 1,
		Samples:  2048,
	}

	// Open the default audio device
	dev, err := sdl.OpenAudioDevice("", false, spec, nil, 0)
	if err != nil {
		return err
	}
	a.Device = dev

	return nil
}

// Play unpauses the audio device to resume sound output.
// Use this when the sound timer is greater than 0 to start/beep.
func (a *SDLAudio) Play() error {
	if err := a.generateBeep(); err != nil {
		return err
	}

	sdl.PauseAudioDevice(a.Device, false)
	return nil
}

// Pause pauses the audio device to stop sound output.
// Use this when the sound timer reaches 0 to silence the beep.
func (a *SDLAudio) Pause() error {
	sdl.PauseAudioDevice(a.Device, true)
	return nil
}

// Close stops and releases the audio device resources.
// It first silences the device by pausing, then closes the SDL audio device,
// and finally resets the device ID to 0. Safe to call multiple times.
func (a *SDLAudio) Close() error {
	if a.Device != 0 {
		sdl.PauseAudioDevice(a.Device, true) // Silence first
		sdl.CloseAudioDevice(a.Device)
		a.Device = 0 // Reset ID
	}

	return nil
}

// generateBeep generates a 440Hz square wave tone and queues it to the audio buffer.
// The tone plays for approximately 1 second (one full cycle at SampleRate).
// It returns early if there's already sufficient audio queued to avoid buffering overflow.
//
// A square wave alternates between positive and negative amplitude:
//   - First half of period: +3000 (high)
//   - Second half of period: -3000 (low)
func (a *SDLAudio) generateBeep() error {
	// Check if sufficient audio is already queued
	if sdl.GetQueuedAudioSize(a.Device) >= 4096 {
		return nil
	}

	// Generate samples for 1 second of audio
	length := SampleRate
	data := make([]int16, length)
	period := SampleRate / int(Frequency)

	// Generate square wave: alternate between +3000 and -3000
	for i := range length {
		if i%period < (period / 2) {
			data[i] = 3000
		} else {
			data[i] = -3000
		}
	}

	// Convert int16 samples to bytes for SDL
	byteLen := len(data) * 2
	byteData := unsafe.Slice((*byte)(unsafe.Pointer(&data[0])), byteLen)

	// Queue the audio data for playback
	if err := sdl.QueueAudio(a.Device, byteData); err != nil {
		return err
	}

	return nil
}





















		
Audio constants

44.1kHz sample rate, 440Hz beep frequency (standard A4 pitch).

SDLAudio struct

Holds the SDL audio device ID.

WithSDL constructor

Creates the SDLAudio for Audio interface for SDL2.

Init opens audio device

Opens default audio device with mono, 16-bit, 2048 sample buffer.

Play generates and plays

Calls generateBeep() to create audio data, then unpauses the device.

Pause

Pauses the audio device to stop sound.

Close

Pauses, closes the device, resets ID. Safe to call multiple times.

generateBeep square wave

Generates a square wave at 440Hz: alternates between +3000 and -3000 amplitude.

The Assembler

  • Written in Go (again)
  • Three-stage pipeline: Lexer Parser Encoder
  • First pass scans and collects labels with their addresses
  • Second pass parses mnemonics and resolves label references
  • Encoder builds opcodes using bit masking similar to CPU
  • Output: raw binary .ch8 file ready to run

The Assembler

internal/assembler/assembler.go
			// Package assembler provides a two-pass assembler for converting CHIP-8 assembly source code into executable bytecode.
package assembler

import (
	"github.com/arpitchakladar/chip-8/internal/assembler/lexer"
	"github.com/arpitchakladar/chip-8/internal/assembler/parser"
)

// Assembler converts CHIP-8 assembly source code into executable bytecode.
// It uses a two-pass pipeline: lexer scans for labels, then parser generates opcodes.
type Assembler struct {
	// Source contains the raw assembly source code.
	Source string
	// Labels maps label names to their memory addresses.
	Labels map[string]uint16
	// ProgramCounter tracks the current address during assembly.
	ProgramCounter uint16
}

// New creates a new Assembler with the given source code.
// The ProgramCounter starts at 0x200 (CHIP-8 program start address).
// The Labels map is initialized empty and will be populated by the lexer during Assemble().
func New(source string) *Assembler {
	return &Assembler{
		Source:         source,
		Labels:         make(map[string]uint16),
		ProgramCounter: 0x200, // Program start address
	}
}

// Assemble processes the source code and returns the compiled bytecode.
// It performs a two-pass assembly:
//
// First pass (lexer):
//   - Scans for labels and builds a label-to-address map
//   - Collects all instruction lines
//   - Validates that __START and __END markers are present
//
// Second pass (parser):
//   - Converts each instruction to its binary opcode
//   - Resolves label references to their addresses
//   - Validates register indices and immediate values
//
// Returns:
//   - []byte: the compiled bytecode ready to be written to a .ch8 file
//   - error: if either pass fails (lexer or parser error)
func (a *Assembler) Assemble() ([]byte, error) {
	// First pass: Lexer scans for labels
	lexer := lexer.New(a.Source, a.ProgramCounter)
	labels, lines, err := lexer.Lex()
	if err != nil {
		return nil, err
	}

	// Initialize the program bytecode
	var program []byte

	// Second pass: Parser converts instructions to opcodes
	parser := parser.New(labels)

	// Process each instruction line from the lexer
	for _, line := range lines {
		opcode, err := parser.Parse(line.Mnemonic, line.Args, line.LineNumber)
		if err != nil {
			return nil, err
		}
		program = append(program, opcode...)
	}

	return program, nil
}





















		
Lexing

Read the assembly file and get all the labels along with their addresses, and also each line stripping all comments.

Creating the parser

We create the parser by passing the labels along with their corresponding addresses. The parser needs access to them for each line.

Parsing line by line

Now we can pass each line to the parser and it will give us a byte array, containing the opcode (in case of instruction) or raw binary data (in case of DB or DW).

Creating the final executable

We then use the byte array returned by the parser and just append it to our program, which is also a byte array.

The Lexer

internal/assembler/lexer/lexer.go
			// Package lexer provides tokenization and label resolution for the CHIP-8 assembler.
// It performs the first pass of assembly, scanning for labels and building a label-to-address map.
package lexer

import "strings"

// Lexer tokenizes CHIP-8 assembly source code and performs the first pass of assembly.
// It scans for labels and collects them into a map for the parser to use.
// It holds the state for tokenizing assembly source code.
type Lexer struct {
	// Source is the raw assembly source code.
	Source string
	// CurrentAddr tracks the current address during scanning.
	CurrentAddr uint16
}

// Line represents a single instruction line parsed from the source.
type Line struct {
	// Mnemonic is the instruction name (e.g., "LD", "JP", "ADD").
	Mnemonic string
	// Args contains the instruction arguments.
	Args []string
	// Address is the memory address where this instruction will be placed.
	Address uint16
	// LineNumber is the original source line number (for error reporting).
	LineNumber uint16
}

// New creates a new Lexer with the given source code and starting address.
func New(source string, currentAddr uint16) *Lexer {
	return &Lexer{
		Source:      source,
		CurrentAddr: currentAddr,
	}
}

// Lex performs the first pass of assembly.
// It scans the source code for labels and builds a map of label names to their addresses.
// It also collects all instruction lines for the parser to process in the second pass.
//
// The function enforces the following rules:
//   - __START must be defined before any instructions
//   - __END must be defined after all instructions
//   - Both __START and __END markers are required
//
// Returns:
//   - labels: map of label name -> memory address
//   - program: list of instruction lines to be parsed
//   - error: if any validation fails
func (l *Lexer) Lex() (map[string]uint16, []Line, error) {
	labels := make(map[string]uint16)
	var program []Line

	i := uint16(0)
	seenStart := false
	seenEnd := false

	for raw := range strings.SplitSeq(l.Source, "\n") {
		i++
		content := strings.Split(raw, ";")[0]
		content = strings.TrimSpace(content)
		if content == "" {
			continue
		}

		if label, found := strings.CutSuffix(content, ":"); found {
			seenStart, seenEnd = l.processLabel(
				label,
				seenStart,
				seenEnd,
				labels,
			)
			continue
		}

		if !seenStart {
			return nil, nil, &StartAfterCodeError{LineNumber: i}
		}
		if seenEnd {
			return nil, nil, &EndAfterCodeError{LineNumber: i}
		}

		parts := strings.Fields(strings.ReplaceAll(content, ",", " "))

		if len(parts) > 0 {
			mnemonic := strings.ToUpper(parts[0])
			program = append(program, Line{
				Mnemonic:   mnemonic,
				Args:       parts[1:],
				Address:    l.CurrentAddr,
				LineNumber: i,
			})
			if mnemonic != "DB" {
				l.CurrentAddr += 2
			} else {
				l.CurrentAddr++
			}
		}
	}

	if !seenStart {
		return nil, nil, &MissingStartLabelError{}
	}
	if !seenEnd {
		return nil, nil, &MissingEndLabelError{}
	}

	return labels, program, nil
}

func (l *Lexer) processLabel(
	label string,
	seenStart, seenEnd bool,
	labels map[string]uint16,
) (bool, bool) {
	switch label {
	case "__START":
		labels[label] = l.CurrentAddr
		return true, seenEnd
	case "__END":
		labels[label] = l.CurrentAddr
		return seenStart, true
	default:
		labels[label] = l.CurrentAddr
		return seenStart, seenEnd
	}
}





















		
Lexing

The lexer returns a map of labels to addresses and a slice of Line structs containing the mnemonic and arguments.

Comment stripping

Everything after ; is treated as a comment and stripped away before processing.

Label detection

Lines ending with : are labels. The current address is stored for that label.

Tokenizing

For non-label lines, we split by whitespace and comma to get the mnemonic and arguments.

Building the program

Each line becomes a Line struct with mnemonic, args, address, and line number for error reporting.

Address tracking

Most instructions are 2 bytes, but DB (define byte) is only 1 byte. The address counter is incremented accordingly.

__START and __END enforcing

We are using __START and __END for denoting the starting and ending of our assembly code. This is done as we are not using a linker.

The Encoder

internal/assembler/encoder/encoder.go
			// Package encoder provides opcode encoding utilities for the CHIP-8 assembler.
// It builds 16-bit opcodes from instruction components using bit masks.
package encoder

// Encoder builds CHIP-8 opcodes from instruction components.
// It provides methods for encoding different instruction formats using bit masks.

// Instruction represents a 16-bit CHIP-8 opcode.
type Instruction uint16

// Encoder builds CHIP-8 opcodes from their component parts.
type Encoder struct{}

// New creates a new instance of the Encoder.
func New() *Encoder {
	return new(Encoder)
}

// Instruction Masks (Constants associated with the Encoder logic).
const (
	MaskCLS  uint16 = 0x00E0
	MaskRET  uint16 = 0x00EE
	MaskJP   uint16 = 0x1000
	MaskCALL uint16 = 0x2000
	MaskSE   uint16 = 0x3000
	MaskSNE  uint16 = 0x4000
	MaskSER  uint16 = 0x5000
	MaskLD   uint16 = 0x6000
	MaskADD  uint16 = 0x7000
	MaskALU  uint16 = 0x8000
	MaskSNER uint16 = 0x9000
	MaskLDI  uint16 = 0xA000
	MaskJPV0 uint16 = 0xB000
	MaskRND  uint16 = 0xC000
	MaskDRW  uint16 = 0xD000
	MaskKEY  uint16 = 0xE000
	MaskMISC uint16 = 0xF000
)

// Addr handles instructions with a 12-bit address (nnn).
// Used by: JP (1nnn), CALL (2nnn), LD I (Annn), JP V0 (Bnnn).
func (e *Encoder) Addr(prefix uint16, addr uint16) uint16 {
	return prefix | (addr & 0x0FFF)
}

// RegImm handles instructions with a register and an 8-bit immediate (xkk).
// Used by: SE (3xkk), SNE (4xkk), LD (6xkk), ADD (7xkk).
func (e *Encoder) RegImm(prefix uint16, vx uint8, valueByte uint8) uint16 {
	return prefix | (uint16(vx&0xF) << 8) | uint16(valueByte)
}

// RegReg handles instructions with two registers (xy).
// Used by: SE (5xy0), LD (8xy0), ALU operations (8xy1-E), SNE (9xy0).
func (e *Encoder) RegReg(
	prefix uint16,
	vx uint8,
	vy uint8,
	suffix uint16,
) uint16 {
	return prefix | (uint16(vx&0xF) << 8) | (uint16(vy&0xF) << 4) | (suffix & 0xF)
}

// RegNibble handles instructions with two registers and a 4-bit nibble (xyn).
// Used by: DRW (Dxyn).
func (e *Encoder) RegNibble(prefix uint16, vx uint8, vy uint8, n uint8) uint16 {
	return prefix | (uint16(vx&0xF) << 8) | (uint16(vy&0xF) << 4) | uint16(
		n&0xF,
	)
}

// RegOnly handles instructions that only specify one register (x).
// Used by: SKP (Ex9E), SKNP (ExA1), etc.
func (e *Encoder) RegOnly(prefix uint16, vx uint8, suffix uint16) uint16 {
	return prefix | (uint16(vx&0xF) << 8) | (suffix & 0xFF)
}

// Raw returns instructions that have no variables.
// Used by: CLS (00E0), RET (00EE).
func (e *Encoder) Raw(opcode uint16) uint16 {
	return opcode
}





















		
Opcode masks

The encoder defines constant masks for each instruction type. These are the fixed bits that identify the opcode.

12-bit address

Used by JP, CALL, LD I, JP V0 - the address is masked to 12 bits (0x0FFF) and OR'd with the prefix.

Register + 8-bit immediate

Used by SE, SNE, LD, ADD - the register goes in bits 8-11, the immediate value in bits 0-7.

Two registers

Used by ALU ops and register-register comparisons - Vx in bits 8-11, Vy in bits 4-7, suffix in bits 0-3.

Display operation

DRW uses Vx, Vy, and a 4-bit nibble (n) for sprite height.

Single register with suffix

Used by SKP, SKNP, and various LD variants - register in bits 8-11, suffix/constant in lower byte.

No-operand opcodes

CLS and RET have no variables - the mask is the entire opcode.

The Parser

internal/assembler/parser/parser.go
			// Package parser provides opcode generation for the CHIP-8 assembler.
// It performs the second pass of assembly, converting instructions to binary opcodes.
package parser

import (
	"encoding/binary"
	"strconv"
	"strings"

	"github.com/arpitchakladar/chip-8/internal/assembler/encoder"
)

// Parser converts CHIP-8 assembly instructions into binary opcodes.
// It uses the label map from the lexer to resolve label references.
type Parser struct {
	// Labels maps label names to their memory addresses (from lexer).
	Labels map[string]uint16
	// Encoder builds the binary opcode representations.
	Encoder *encoder.Encoder
}

// New creates a new Parser with the given label map.
func New(labels map[string]uint16) *Parser {
	return &Parser{
		Labels:  labels,
		Encoder: encoder.New(),
	}
}

// Parse converts a single assembly instruction into its binary opcode representation.
// It resolves labels to their addresses and validates arguments.
//
// The function handles all CHIP-8 opcodes:
//   - Flow control: CLS, RET, JP, CALL
//   - Conditional: SE, SNE
//   - Arithmetic: ADD, SUB, SUBN, AND, OR, XOR, SHR, SHL
//   - Memory: LD (various forms)
//   - Display: DRW
//   - Input: SKP, SKNP
//   - Random: RND
//   - Data: DB (1-byte), DW (2-byte)
//
// Arguments:
//   - mnemonic: the instruction name (e.g., "LD", "JP")
//   - args: the instruction arguments (e.g., ["V0", "0x10"])
//   - line: the source line number for error reporting
//
// Returns:
//   - []byte: the binary opcode (2 bytes for most instructions, 1 for DB)
//   - error: if the mnemonic is unknown, arguments are invalid, or values are out of range
func (p *Parser) Parse(
	mnemonic string,
	args []string,
	line uint16,
) ([]byte, error) {
	upperMnemonic := strings.ToUpper(mnemonic)

	if mask, ok := simpleHandlers[upperMnemonic]; ok {
		return p.toBinary(p.Encoder.Raw(mask)), nil
	}

	return p.dispatchParse(upperMnemonic, args, line)
}

var simpleHandlers = map[string]uint16{
	"CLS": encoder.MaskCLS,
	"RET": encoder.MaskRET,
}

type parseHandler func(*Parser, string, []string, uint16) ([]byte, error)

var mnemonicHandlers = map[string]parseHandler{
	"JP":   (*Parser).handleJP,
	"CALL": (*Parser).handleCall,
	"SE":   (*Parser).handleSkipOp,
	"SNE":  (*Parser).handleSkipOp,
	"ADD":  (*Parser).handleAdd,
	"OR":   (*Parser).handleALU,
	"AND":  (*Parser).handleALU,
	"XOR":  (*Parser).handleALU,
	"SUB":  (*Parser).handleALU,
	"SHR":  (*Parser).handleALU,
	"SUBN": (*Parser).handleALU,
	"SHL":  (*Parser).handleALU,
	"LD":   (*Parser).handleLoad,
	"RND":  (*Parser).handleRND,
	"DRW":  (*Parser).handleDRW,
	"SKP":  (*Parser).handleKeySkip,
	"SKNP": (*Parser).handleKeySkip,
	"DW":   (*Parser).handleData,
	"DB":   (*Parser).handleData,
}

func (p *Parser) dispatchParse(
	upperMnemonic string,
	args []string,
	line uint16,
) ([]byte, error) {
	if handler, ok := mnemonicHandlers[upperMnemonic]; ok {
		return handler(p, upperMnemonic, args, line)
	}
	return nil, p.parseErr(
		upperMnemonic,
		args,
		line,
		&UnknownMnemonicError{upperMnemonic, line},
	)
}

func (p *Parser) handleJP(
	mnemonic string,
	args []string,
	line uint16,
) ([]byte, error) {
	switch len(args) {
	case 2:
		if strings.ToUpper(args[0]) == "V0" {
			addr, err := p.resolveValue(args[1], mnemonic, line, 12)
			if err != nil {
				return nil, p.parseErr(mnemonic, args, line, err)
			}
			return p.toBinary(p.Encoder.Addr(encoder.MaskJPV0, addr)), nil
		}
		fallthrough
	case 1:
		addr, err := p.resolveValue(args[0], mnemonic, line, 12)
		if err != nil {
			return nil, p.parseErr(mnemonic, args, line, err)
		}
		return p.toBinary(p.Encoder.Addr(encoder.MaskJP, addr)), nil
	default:
		return nil, p.parseErr(
			mnemonic,
			args,
			line,
			&WrongArgCountError{mnemonic, line, 1, len(args)},
		)
	}
}

func (p *Parser) handleCall(
	mnemonic string,
	args []string,
	line uint16,
) ([]byte, error) {
	if len(args) != 1 {
		return nil, p.parseErr(
			mnemonic,
			args,
			line,
			&WrongArgCountError{mnemonic, line, 1, len(args)},
		)
	}
	addr, err := p.resolveValue(args[0], mnemonic, line, 12)
	if err != nil {
		return nil, p.parseErr(mnemonic, args, line, err)
	}
	return p.toBinary(p.Encoder.Addr(encoder.MaskCALL, addr)), nil
}

func (p *Parser) handleSkipOp(
	mnemonic string,
	args []string,
	line uint16,
) ([]byte, error) {
	immBase, regBase := encoder.MaskSE, encoder.MaskSER
	if mnemonic == "SNE" {
		immBase, regBase = encoder.MaskSNE, encoder.MaskSNER
	}
	res, err := p.handleSkip(immBase, regBase, mnemonic, args, line)
	if err != nil {
		return nil, p.parseErr(mnemonic, args, line, err)
	}
	return res, nil
}

func (p *Parser) handleAdd(
	mnemonic string,
	args []string,
	line uint16,
) ([]byte, error) {
	if len(args) != 2 {
		return nil, p.parseErr(
			mnemonic,
			args,
			line,
			&WrongArgCountError{mnemonic, line, 2, len(args)},
		)
	}
	if args[0] == "I" {
		vx, err := p.parseReg(args[1], mnemonic, line)
		if err != nil {
			return nil, p.parseErr(mnemonic, args, line, err)
		}
		return p.toBinary(p.Encoder.RegOnly(encoder.MaskMISC, vx, 0x1E)), nil
	}
	if p.isRegister(args[1]) {
		return p.handleRegReg(
			encoder.MaskALU,
			mnemonic,
			args,
			0x4,
			line,
		)
	}
	vx, err := p.parseReg(args[0], mnemonic, line)
	if err != nil {
		return nil, p.parseErr(mnemonic, args, line, err)
	}
	val, err := p.resolveValue(args[1], mnemonic, line, 8)
	if err != nil {
		return nil, p.parseErr(mnemonic, args, line, err)
	}
	return p.toBinary(p.Encoder.RegImm(encoder.MaskADD, vx, uint8(val))), nil
}

func (p *Parser) handleALU(
	mnemonic string,
	args []string,
	line uint16,
) ([]byte, error) {
	suffixes := map[string]uint16{
		"OR": 0x1, "AND": 0x2, "XOR": 0x3,
		"SUB": 0x5, "SHR": 0x6, "SUBN": 0x7, "SHL": 0xE,
	}
	suffix, ok := suffixes[mnemonic]
	if !ok {
		return nil, p.parseErr(
			mnemonic,
			args,
			line,
			&UnknownMnemonicError{mnemonic, 0},
		)
	}
	return p.handleRegReg(encoder.MaskALU, mnemonic, args, suffix, line)
}

func (p *Parser) handleRND(
	mnemonic string,
	args []string,
	line uint16,
) ([]byte, error) {
	if len(args) != 2 {
		return nil, p.parseErr(
			mnemonic,
			args,
			line,
			&WrongArgCountError{mnemonic, line, 2, len(args)},
		)
	}
	vx, err := p.parseReg(args[0], mnemonic, line)
	if err != nil {
		return nil, p.parseErr(mnemonic, args, line, err)
	}
	val, err := p.resolveValue(args[1], mnemonic, line, 8)
	if err != nil {
		return nil, p.parseErr(mnemonic, args, line, err)
	}
	return p.toBinary(p.Encoder.RegImm(encoder.MaskRND, vx, uint8(val))), nil
}

func (p *Parser) handleDRW(
	mnemonic string,
	args []string,
	line uint16,
) ([]byte, error) {
	if len(args) != 3 {
		return nil, p.parseErr(
			mnemonic,
			args,
			line,
			&WrongArgCountError{mnemonic, line, 3, len(args)},
		)
	}
	vx, err := p.parseReg(args[0], mnemonic, line)
	if err != nil {
		return nil, p.parseErr(mnemonic, args, line, err)
	}
	vy, err := p.parseReg(args[1], mnemonic, line)
	if err != nil {
		return nil, p.parseErr(mnemonic, args, line, err)
	}
	n, err := p.resolveValue(args[2], mnemonic, line, 4)
	if err != nil {
		return nil, p.parseErr(mnemonic, args, line, err)
	}
	return p.toBinary(
		p.Encoder.RegNibble(encoder.MaskDRW, vx, vy, uint8(n)),
	), nil
}

func (p *Parser) handleKeySkip(
	mnemonic string,
	args []string,
	line uint16,
) ([]byte, error) {
	if len(args) != 1 {
		return nil, p.parseErr(
			mnemonic,
			args,
			line,
			&WrongArgCountError{mnemonic, line, 1, len(args)},
		)
	}
	vx, err := p.parseReg(args[0], mnemonic, line)
	if err != nil {
		return nil, p.parseErr(mnemonic, args, line, err)
	}
	var opcode uint16
	if mnemonic == "SKNP" {
		opcode = 0xA1
	} else {
		opcode = 0x9E
	}
	return p.toBinary(p.Encoder.RegOnly(encoder.MaskKEY, vx, opcode)), nil
}

func (p *Parser) handleData(
	mnemonic string,
	args []string,
	line uint16,
) ([]byte, error) {
	if len(args) != 1 {
		return nil, p.parseErr(
			mnemonic,
			args,
			line,
			&WrongArgCountError{mnemonic, line, 1, len(args)},
		)
	}
	bits := 8
	if mnemonic == "DW" {
		bits = 16
	}
	val, err := p.resolveValue(args[0], mnemonic, line, bits)
	if err != nil {
		return nil, p.parseErr(mnemonic, args, line, err)
	}
	if bits == 16 {
		return p.toBinary(val), nil
	}
	return []byte{byte(val)}, nil
}

// --- Helper Handlers ---

// handleLoad processes LD (load) instructions with various source/destination combinations.
func (p *Parser) handleLoad(
	_ string,
	args []string,
	line uint16,
) ([]byte, error) {
	if len(args) != 2 {
		return nil, &WrongArgCountError{"LD", line, 2, len(args)}
	}
	dst, src := args[0], args[1]

	if dst == "I" {
		addr, err := p.resolveValue(src, "LD", line, 12)
		if err != nil {
			return nil, err
		}
		return p.toBinary(p.Encoder.Addr(encoder.MaskLDI, addr)), nil
	}

	if p.isRegister(dst) {
		return p.handleLoadVxSrc(dst, src, line)
	}

	if p.isRegister(src) {
		return p.handleLoadDstVx(dst, src, line)
	}

	return nil, &InvalidLoadError{line, dst, src}
}

func (p *Parser) handleLoadVxSrc(dst, src string, line uint16) ([]byte, error) {
	vx, err := p.parseReg(dst, "LD", line)
	if err != nil {
		return nil, err
	}
	switch {
	case src == "DT":
		return p.toBinary(p.Encoder.RegOnly(encoder.MaskMISC, vx, 0x07)), nil
	case src == "K":
		return p.toBinary(p.Encoder.RegOnly(encoder.MaskMISC, vx, 0x0A)), nil
	case p.isRegister(src):
		vy, err := p.parseReg(src, "LD", line)
		if err != nil {
			return nil, err
		}
		return p.toBinary(p.Encoder.RegReg(encoder.MaskALU, vx, vy, 0x0)), nil
	case src == "[I]":
		return p.toBinary(p.Encoder.RegOnly(encoder.MaskMISC, vx, 0x65)), nil
	default:
		val, err := p.resolveValue(src, "LD", line, 8)
		if err != nil {
			return nil, err
		}
		return p.toBinary(p.Encoder.RegImm(encoder.MaskLD, vx, uint8(val))), nil
	}
}

func (p *Parser) handleLoadDstVx(dst, src string, line uint16) ([]byte, error) {
	vx, err := p.parseReg(src, "LD", line)
	if err != nil {
		return nil, err
	}
	switch dst {
	case "DT":
		return p.toBinary(p.Encoder.RegOnly(encoder.MaskMISC, vx, 0x15)), nil
	case "ST":
		return p.toBinary(p.Encoder.RegOnly(encoder.MaskMISC, vx, 0x18)), nil
	case "F":
		return p.toBinary(p.Encoder.RegOnly(encoder.MaskMISC, vx, 0x29)), nil
	case "B":
		return p.toBinary(p.Encoder.RegOnly(encoder.MaskMISC, vx, 0x33)), nil
	case "[I]":
		return p.toBinary(p.Encoder.RegOnly(encoder.MaskMISC, vx, 0x55)), nil
	}
	return nil, &InvalidLoadError{line, dst, src}
}

// handleSkip processes SE (skip if equal) and SNE (skip if not equal) instructions.
// It handles both register-to-immediate and register-to-register comparisons.
func (p *Parser) handleSkip(
	immBase, regBase uint16,
	mnemonic string,
	args []string,
	line uint16,
) ([]byte, error) {
	if len(args) != 2 {
		return nil, &WrongArgCountError{mnemonic, line, 2, len(args)}
	}
	vx, err := p.parseReg(args[0], mnemonic, line)
	if err != nil {
		return nil, err
	}

	if p.isRegister(args[1]) {
		vy, err := p.parseReg(args[1], mnemonic, line)
		if err != nil {
			return nil, err
		}
		return p.toBinary(p.Encoder.RegReg(regBase, vx, vy, 0x0)), nil
	}

	val, err := p.resolveValue(args[1], mnemonic, line, 8)
	if err != nil {
		return nil, err
	}
	return p.toBinary(p.Encoder.RegImm(immBase, vx, uint8(val))), nil
}

// handleRegReg processes two-register arithmetic/logic instructions (OR, AND, XOR, SUB, etc.).
func (p *Parser) handleRegReg(
	base uint16,
	mnemonic string,
	args []string,
	suffix uint16,
	line uint16,
) ([]byte, error) {
	if len(args) != 2 {
		return nil, &WrongArgCountError{mnemonic, line, 2, len(args)}
	}
	vx, err := p.parseReg(args[0], mnemonic, line)
	if err != nil {
		return nil, err
	}
	vy, err := p.parseReg(args[1], mnemonic, line)
	if err != nil {
		return nil, err
	}
	return p.toBinary(p.Encoder.RegReg(base, vx, vy, suffix)), nil
}

// --- Utility Functions ---

// toBinary converts a 16-bit opcode to a 2-byte slice in big-endian format.
func (p *Parser) toBinary(opcode uint16) []byte {
	buf := make([]byte, 2)
	binary.BigEndian.PutUint16(buf, opcode)
	return buf
}

// isRegister returns true if the string looks like a register (e.g., "V0", "VF").
func (p *Parser) isRegister(s string) bool {
	s = strings.ToUpper(s)
	return len(s) >= 2 && s[0] == 'V'
}

// parseReg parses a register string (Vx) and returns the register index.
// It returns an error if the string is not a valid register.
func (p *Parser) parseReg(
	s string,
	mnemonic string,
	line uint16,
) (uint8, error) {
	if !p.isRegister(s) {
		return 0, &InvalidRegisterError{mnemonic, line, s}
	}

	val, err := strconv.ParseUint(s[1:], 16, 8)
	if err != nil || val > 0xF {
		return 0, &InvalidRegisterError{mnemonic, line, s}
	}

	return uint8(val), nil
}

// resolveValue resolves a string to a numeric value.
// It first checks the label map, then tries to parse as a literal (hex or decimal).
// The bits parameter specifies the maximum bit width for range checking.
func (p *Parser) resolveValue(
	s string,
	mnemonic string,
	line uint16,
	bits int,
) (uint16, error) {
	var val uint16
	var ok bool

	// 1. Try Labels
	if val, ok = p.Labels[s]; !ok {
		// 2. Try Literal (Hex or Dec)
		clean := strings.ReplaceAll(
			strings.ReplaceAll(strings.ReplaceAll(s, "0X", ""), "0x", ""),
			"$",
			"",
		)
		base := 10
		if strings.Contains(strings.ToUpper(s), "0X") ||
			strings.Contains(s, "$") {
			base = 16
		}

		v64, err := strconv.ParseUint(clean, base, 16)
		if err != nil {
			// If it's not a number and not in labels, it's an unresolved label
			// (assuming labels don't start with numbers)
			if base == 10 && (s[0] < '0' || s[0] > '9') {
				return 0, &UnresolvedLabelError{mnemonic, line, s}
			}
			return 0, &InvalidImmediateError{mnemonic, line, s, err}
		}
		val = uint16(v64)
	}

	// 3. Range Check
	maxValue := (uint32(1) << bits) - 1
	if uint32(val) > maxValue {
		return 0, &ImmediateOutOfRangeError{mnemonic, line, s, val, bits}
	}

	return val, nil
}

// parseErr wraps a child error into a ParseError with context about the failing instruction.
func (p *Parser) parseErr(
	mnemonic string,
	args []string,
	line uint16,
	child error,
) error {
	// If it's already a ParseError, don't double wrap
	if _, ok := child.(*ParseError); ok {
		return child
	}
	return &ParseError{
		Mnemonic:   mnemonic,
		Args:       args,
		LineNumber: line,
		Child:      child,
	}
}





















		
The parse function

The Parse method takes a mnemonic and arg and dispatches to the appropriate handler.

The big switch is back

A big switch statement routes each mnemonic to its handler.

WebAssembly

  • Compile with GOOS=js GOARCH=wasm
  • Runs in browsers with HTML5 Canvas
  • Uses Web Audio API for sound
  • Same emulator and assembler code as desktop

JavaScript API

WASM Implementation

internal/emulator/wasm.go
			//go:build wasm && js

// Package emulator provides the core CHIP-8 emulator functionality.
package emulator

import (
	"sync"
	"syscall/js"

	"github.com/arpitchakladar/chip-8/internal/emulator/audio"
	"github.com/arpitchakladar/chip-8/internal/emulator/cpu"
	"github.com/arpitchakladar/chip-8/internal/emulator/display"
	"github.com/arpitchakladar/chip-8/internal/emulator/keyboard"
	"github.com/arpitchakladar/chip-8/internal/emulator/memory"
)

const (
	// MaxTickRate is limited to 250Hz in WASM due to browser timer resolution.
	// runCPU uses batch execution to achieve higher effective clock speeds.
	MaxTickRate = 250
)

// WithWASM creates a new Emulator configured for WebAssembly/JavaScript execution.
// It uses HTML5 Canvas for display and Web Audio API for sound via the WASM implementations.
//
// Parameters:
//   - canvas: A JavaScript canvas element reference for rendering graphics
//   - clockSpeed: CPU instructions per second (e.g., 100000 for 100kHz)
//
// Returns a configured Emulator ready to load and run CHIP-8 ROMs.
func WithWASM(canvas js.Value, clockSpeed uint32) *Emulator {
	e := &Emulator{
		CPU:        cpu.New(),
		Memory:     memory.New(),
		Display:    display.WithWASM(canvas),
		Keyboard:   keyboard.WithWASM(),
		Audio:      audio.WithWASM(),
		memoryLock: sync.Mutex{},
		ClockSpeed: clockSpeed,
	}

	e.Memory.LoadFontSet()
	e.CPU.ProgramCounter = ProgramStart
	return e
}





















		
Creating WASM emulator

WithWASM takes a canvas element and clock speed and creates the emulator with WASM-specific display, keyboard, and audio.

MaxTickRate limitation

WASM has a MaxTickRate of 250Hz due to browser timer resolution limitations. The CPU loop uses batch execution to achieve higher effective clock speeds.

WASM Display

internal/emulator/display/wasm.go
			//go:build wasm && js

// Package display provides a WebAssembly-compatible display implementation
// using the HTML5 Canvas API.
package display

import (
	"fmt"
	"syscall/js"
)

// WASMDisplay implements the Display interface for WebAssembly/JS environments.
// It uses an HTML5 Canvas element to render CHIP-8 graphics through the 2D context.
type WASMDisplay struct {
	buffer    *DisplayBuffer
	Canvas    js.Value
	ctx       js.Value
	imageData js.Value
	data      []byte
}

// WithWASM creates a new Display that uses an HTML5 Canvas for rendering.
// The canvas parameter should be a JavaScript canvas element reference.
func WithWASM(canvas js.Value) Display {
	return &WASMDisplay{
		buffer: NewDisplayBuffer(),
		Canvas: canvas,
	}
}

// Init initializes the WASM display.
// It gets the 2D rendering context, sets the canvas to native 64x32 resolution,
// preserves the original canvas dimensions via CSS, and pre-allocates the pixel buffer.
func (d *WASMDisplay) Init() error {
	if d.Canvas.IsNull() || d.Canvas.IsUndefined() {
		return nil
	}

	d.ctx = d.Canvas.Call("getContext", "2d")
	if d.ctx.IsNull() || d.ctx.IsUndefined() {
		return nil
	}

	canvasWidth := d.Canvas.Get("width").Int()
	canvasHeight := d.Canvas.Get("height").Int()

	d.Canvas.Set("width", Width)
	d.Canvas.Set("height", Height)

	style := d.Canvas.Get("style")
	style.Set("width", js.ValueOf(fmt.Sprintf("%dpx", canvasWidth)))
	style.Set("height", js.ValueOf(fmt.Sprintf("%dpx", canvasHeight)))
	style.Set("image-rendering", js.ValueOf("pixelated"))

	d.imageData = d.ctx.Call("createImageData", Width, Height)
	d.data = make([]byte, Width*Height*4)

	return nil
}

func (d *WASMDisplay) Clear() {
	d.buffer.Clear()
}

// SetPixel delegates to the display buffer for XOR pixel drawing.
// Returns true if the pixel was already on (collision detection for sprites).
func (d *WASMDisplay) SetPixel(x, y uint8) (bool, error) {
	return d.buffer.SetPixel(x, y)
}

// Present renders the current display buffer to the HTML5 Canvas.
// Uses ImageData for fast pixel rendering.
func (d *WASMDisplay) Present() error {
	if d.ctx.IsNull() || d.ctx.IsUndefined() {
		return nil
	}

	pixels := d.buffer.GetPixels()

	for i, val := range pixels {
		offset := i * 4
		if val == 1 {
			d.data[offset] = 255
			d.data[offset+1] = 255
			d.data[offset+2] = 255
			d.data[offset+3] = 255
		} else {
			d.data[offset] = 0
			d.data[offset+1] = 0
			d.data[offset+2] = 0
			d.data[offset+3] = 255
		}
	}

	jsData := d.imageData.Get("data")
	js.CopyBytesToJS(jsData, d.data)

	d.ctx.Call("putImageData", d.imageData, 0, 0)

	return nil
}

func (d *WASMDisplay) Close() error {
	return nil
}

func (d *WASMDisplay) GetPixels() []byte {
	return d.buffer.GetPixels()
}





















		
WASMDisplay struct

Holds pixel buffer, cached 2D context, ImageData, and pre-allocated byte slice for RGBA data.

WithWASM

Takes a canvas element from JavaScript and creates the display.

Init

Gets 2D context, sets canvas to native 64x32, preserves dimensions via CSS, pre-allocates pixel buffer.

Present

Writes RGBA to byte slice, uses js.CopyBytesToJS to copy to ImageData, calls putImageData once per frame (fast).

WASM Keyboard

internal/emulator/keyboard/wasm.go
			//go:build wasm && js

// Package keyboard provides a WebAssembly-compatible keyboard implementation.
package keyboard

// WASMKeyboard implements the Keyboard interface for WebAssembly/JS environments.
// Key state is managed through SetKey() which should be connected to JavaScript
// keydown/keyup event handlers. Supports CHIP-8's standard 16-key layout (0-F).
type WASMKeyboard struct {
	Keys [16]bool // State of each of the 16 keys (true = pressed)
}

// WithWASM creates a new Keyboard implementation for WebAssembly.
// Returns a WASMKeyboard that must be connected to DOM event handlers.
func WithWASM() Keyboard {
	return &WASMKeyboard{}
}

// IsKeyPressed returns true if the specified key is currently pressed.
// The key parameter should be a value from 0-15 representing CHIP-8 keys.
func (kb *WASMKeyboard) IsKeyPressed(key byte) bool {
	return key <= 15 && kb.Keys[key]
}

// AnyKeyPressed returns the first pressed key and true if any key is pressed.
// Used for CHIP-8's wait-for-key instruction (FX0A).
// Returns (0, false) if no keys are pressed.
func (kb *WASMKeyboard) AnyKeyPressed() (byte, bool) {
	for i, isPressed := range kb.Keys {
		if isPressed {
			return byte(i), true
		}
	}
	return 0, false
}

// SetKey updates the pressed state of a key.
// The key parameter should be 0-15, pressed should be true for keydown, false for keyup.
func (kb *WASMKeyboard) SetKey(key byte, pressed bool) {
	if key <= 15 {
		kb.Keys[key] = pressed
	}
}

// PollEvents is a no-op for WASM keyboard since events are pushed directly via SetKey.
// Kept for interface compatibility with SDL keyboard implementation.
func (kb *WASMKeyboard) PollEvents() {}





















		
WASMKeyboard struct

Simple array of 16 booleans for key states.

WithWASM

Creates a new WASMKeyboard (must be connected to JS event handlers).

IsKeyPressed/AnyKeyPressed

Same logic as SDL keyboard implementation.

SetKey

JavaScript calls this from keydown/keyup event handlers to update state.

PollEvents no-op

Events are pushed via SetKey, not polled. Interface compatibility only.

WASM Audio

internal/emulator/audio/wasm.go
			//go:build wasm && js

// Package audio provides a WebAssembly-compatible audio implementation
// using the Web Audio API.
package audio

import (
	"syscall/js"
)

// WASMAudio implements the Audio interface for WebAssembly/JS environments.
// It uses the Web Audio API to generate square wave tones for CHIP-8 sound.
type WASMAudio struct {
	AudioContext js.Value // Web Audio API AudioContext
	Oscillator   js.Value // Active oscillator node (if playing)
	GainNode     js.Value // Gain node for volume control
	playing      bool     // Track whether audio is currently playing
}

// WithWASM creates a new Audio implementation for WebAssembly.
// Initializes the Web Audio API AudioContext for generating sound.
func WithWASM() Audio {
	ctx := js.Global().Get("AudioContext")
	if ctx.IsUndefined() || ctx.IsNull() {
		// Safari fallback
		ctx = js.Global().Get("webkitAudioContext")
	}

	var audioCtx js.Value
	if !ctx.IsUndefined() && !ctx.IsNull() {
		audioCtx = ctx.New()
	}

	return &WASMAudio{
		AudioContext: audioCtx,
	}
}

func (a *WASMAudio) Init() error {
	// Lazily initialize AudioContext on first use (browser requires user gesture)
	if a.AudioContext.IsUndefined() || a.AudioContext.IsNull() {
		a.AudioContext = js.Global().Get("AudioContext").New()
	}
	return nil
}

// Play starts playing a square wave tone using the Web Audio API.
// Creates an oscillator and gain node, connects them to the audio destination.
// Safe to call multiple times; returns early if already playing.
func (a *WASMAudio) Play() error {
	if a.playing {
		return nil
	}

	ctx := a.AudioContext
	if ctx.IsUndefined() || ctx.IsNull() {
		ctx = js.Global().Get("AudioContext").New()
		a.AudioContext = ctx
	}

	// Resume context (must be user-triggered!)
	if ctx.Get("state").String() == "suspended" {
		ctx.Call("resume")
	}

	osc := ctx.Call("createOscillator")
	gain := ctx.Call("createGain")

	// Correct parameter setting
	osc.Set("type", "square")
	osc.Get("frequency").Set("value", 440)

	gain.Get("gain").Set("value", 0.1)

	// Connect nodes
	osc.Call("connect", gain)
	gain.Call("connect", ctx.Get("destination"))

	osc.Call("start")

	a.Oscillator = osc
	a.GainNode = gain
	a.playing = true

	return nil
}

// Pause stops the currently playing sound by stopping the oscillator.
// Safe to call multiple times; returns early if not playing.
func (a *WASMAudio) Pause() error {
	if !a.playing {
		return nil
	}

	if !a.Oscillator.IsUndefined() && !a.Oscillator.IsNull() {
		a.Oscillator.Call("stop")
	}

	a.Oscillator = js.Value{}
	a.GainNode = js.Value{}
	a.playing = false

	return nil
}

// Close stops any playing audio and releases resources.
// Implements the io.Closer interface.
func (a *WASMAudio) Close() error {
	return a.Pause()
}





















		
WASMAudio struct

Holds AudioContext, oscillator, and gain node references.

WithWASM

Creates AudioContext from Web Audio API (with Safari fallback).

Init

Lazy initialization (browser requires user gesture to start audio).

Play creates oscillator

Creates square wave oscillator at 440Hz, connects to gain node, then to destination.

Pause stops sound

Calls stop() on the oscillator.

The Snake Game

  • Control scheme: Q (left), E (right), 2 (up), S (down)
  • Movement update every ~166ms using delay timer
  • Snake stored in RAM at SNAKE_BODY_DATA as X,Y coordinate pairs
  • Collision detection with body and food
  • Food generates at random 64×32 position
  • Screen wrapping on edges (mask with 0x3F and 0x1F)
  • Beep plays on eating food and game over
  • "GAME OVER" drawn using 8×8 sprite data at end

The Snake Game

Gameplay

The Snake Game

games/snake.asm
			__START:
	CALL INITIALIZE
	CALL GENERATE_AND_DRAW_FOOD
	CALL DRAW_SNAKE
	LOOP:
		LD V0, 10        ; Delay for ~166ms (10/60 seconds)
		LD DT, V0

	WAIT_LOOP:
		CALL CHECK_INPUT
		LD V0, DT
		SNE V0, 0        ; Wait for timer to hit zero
		JP TRIGGER_MOVE
		JP WAIT_LOOP

	TRIGGER_MOVE:
		CALL MOVE_SNAKE
		JP LOOP

INITIALIZE:
	LD V0, 1          ; Vel X
	LD V1, 0          ; Vel Y
	LD V2, 4          ; Length

	; Save these initial values to our RAM labels
	LD I, SNAKE_VEL_X
	LD [I], V2        ; Stores V0, V1, V2, V3, and V4 into RAM

	LD I, SNAKE_BODY_DATA
	LD V0, 32         ; Initial X axis of snake head
	LD V1, 16         ; Initial Y axis of snake head
	LD V3, 0          ; Loop counter
	LD V4, 2
	LD V5, 1
	INITIALIZE_SNAKE_BODY_LOOP:
		SUB V0, V5 ; Decrement the X position by 1 (for the next body part)
		LD [I], V1
		ADD V3, 1
		ADD I, V4
		SE V3, V2 ; Add as many bodies as length of snake
		JP INITIALIZE_SNAKE_BODY_LOOP
	RET

REMOVE_OLD_FOOD:
	LD I, FOOD_X
	LD V1, [I]    ; Read coordinates from memory
	LD I, SPRITE_DOT
	DRW V0, V1, 1 ; Remove food
	RET

GENERATE_AND_DRAW_FOOD:
	RND V0, 0x3F  ; Generate random X coordinate
	RND V1, 0x1F  ; Generate random Y coordinate
	LD I, FOOD_X
	LD [I], V1    ; Save coordinates to memory
	LD I, SPRITE_DOT
	DRW V0, V1, 1 ; Draw food
	RET

CHECK_INPUT:
	; --- Check Key 2 (UP, key 2) ---
	LD V0, 0x02
	SKNP V0            ; If Key 2 is pressed, don't skip
	CALL SET_UP

	; --- Check Key 8 (DOWN, key s) ---
	LD V0, 0x08
	SKNP V0
	CALL SET_DOWN

	; --- Check Key 4 (LEFT, key q) ---
	LD V0, 0x04
	SKNP V0
	CALL SET_LEFT

	; --- Check Key 6 (RIGHT, key e) ---
	LD V0, 0x06
	SKNP V0
	CALL SET_RIGHT
	RET

; --- Direction Setters ---
; We update the VelX (V0) and VelY (V1) and save them to RAM
SET_UP:
	LD  I, SNAKE_VEL_X
	LD  V1, [I]           ; Load current VelY into V2
	SNE  V1, 1             ; If VelY == 1 (moving DOWN), skip the RET
	RET                   ; Already moving down — ignore
	LD  V0, 0
	LD  V1, 0xFF
	LD  I, SNAKE_VEL_X
	LD  [I], V1
	RET

SET_DOWN:
	LD  I, SNAKE_VEL_X
	LD  V1, [I]
	SNE  V1, 0xFF          ; If VelY == 0xFF (moving UP), skip the RET
	RET                   ; Already moving up — ignore
	LD  V0, 0
	LD  V1, 1
	LD  I, SNAKE_VEL_X
	LD  [I], V1
	RET

SET_LEFT:
	LD  I, SNAKE_VEL_X
	LD  V1, [I]
	SNE  V0, 1             ; If VelX == 1 (moving RIGHT), skip the RET
	RET                   ; Already moving right — ignore
	LD  V0, 0xFF
	LD  V1, 0
	LD  I, SNAKE_VEL_X
	LD  [I], V1
	RET

SET_RIGHT:
	LD  I, SNAKE_VEL_X
	LD  V1, [I]
	SNE  V0, 0xFF          ; If VelX == 0xFF (moving LEFT), skip the RET
	RET                   ; Already moving left — ignore
	LD  V0, 1
	LD  V1, 0
	LD  I, SNAKE_VEL_X
	LD  [I], V1
	RET

MOVE_SNAKE:
	LD I, SNAKE_LEN
	LD V0, [I]
	LD V3, V0 ; Get snake length
	ADD V3, V0 ; Get snake length (*2 for the fact each body is 2 bytes)
	LD V4, 2 ; for decrementing counter

	CHECK_COLLISION_WITH_BODY:
		; Load position of the snake head
		LD I, SNAKE_BODY_DATA
		LD V1, [I]

		LD V5, V0
		LD V6, V1

		LD V7, V3
		CHECK_COLLISION_WITH_BODY_LOOP:
			SUB V7, V4

			; Load coordinates of snake body piece
			LD I, SNAKE_BODY_DATA
			ADD I, V7

			LD V1, [I]

			; Check if the corrdinates are same
			SE V0, V5
			JP CHECK_COLLISION_WITH_BODY_LOOP_continue
			SE V1, V6
			JP CHECK_COLLISION_WITH_BODY_LOOP_continue

			JP STOP_GAME

			CHECK_COLLISION_WITH_BODY_LOOP_continue:
			SE V7, 2
			JP CHECK_COLLISION_WITH_BODY_LOOP

	CHECK_COLLISION_WITH_FOOD:
		; Load position of food
		LD I, FOOD_X
		LD V1, [I]

		LD V5, V0
		LD V6, V1

		; Load position of snake head
		LD I, SNAKE_BODY_DATA
		LD V1, [I]

		SE V5, V0
		JP REMOVE_TAIL
		SNE V6, V1 ; Collision of head with food
		JP PRESERVE_TAIL
		JP REMOVE_TAIL

		PRESERVE_TAIL: ; Make the snake grow
			; Prevent the tail from getting deleted
			ADD V3, V4

			; Increase length of snake
			LD I, SNAKE_LEN
			LD V0, [I]
			ADD V0, 1
			LD [I], V0

			; Play sound
			CALL PLAY_BEEP

			; Draw new food
			CALL REMOVE_OLD_FOOD
			CALL GENERATE_AND_DRAW_FOOD
			JP START_MAKE_SNAKE_LOOP

		REMOVE_TAIL:
			SUB V3, V4 ; Get the index of last body part

			; Draw over the last tail to remove it
			LD I, SNAKE_BODY_DATA
			ADD I, V3
			LD V1, [I]
			LD I, SPRITE_DOT
			DRW V0, V1, 1 ; Reset the tail of the sna

			; Reset the registors
			ADD V3, V4
			JP START_MAKE_SNAKE_LOOP ; make (to make it move forward)


	START_MAKE_SNAKE_LOOP:
		MOVE_SNAKE_LOOP:
			; Decrement the index counter for snake body to get the
			; body just ahead of this one
			; V3 gets subtracted by 2 (each snake body is 2 bytes)
			SUB V3, V4

			; Loading the position of the current snake body
			LD I, SNAKE_BODY_DATA
			ADD I, V3
			LD V1, [I]

			; Getting the position of the current snake body piece
			; Add 2 for reseting the changes used to get previous body
			ADD V3, V4
			LD I, SNAKE_BODY_DATA
			ADD I, V3
			LD [I], V1

			; Finally decrementing the counter
			SUB V3, V4

			SE V3, 0
			JP MOVE_SNAKE_LOOP

	LD I, SNAKE_VEL_X
	LD V1, [I]
	LD V2, V0
	LD V3, V1
	LD I, SNAKE_BODY_DATA
	LD V1, [I]
	ADD V0, V2
	ADD V1, V3

	LD V4, 0x3F   ; Mask for 63 (Width - 1)
	AND V0, V4    ; If V0 was 64, it now becomes 0

	LD V4, 0x1F   ; Mask for 31 (Height - 1)
	AND V1, V4    ; If V1 was 32, it now becomes 0

	LD [I], V1
	LD I, SPRITE_DOT
	DRW V0, V1, 1 ; Create the new head
	RET

DRAW_SNAKE:
	; 1. Load snake information from RAM back into registers
	LD I, SNAKE_LEN
	LD V0, [I]
	LD V2, V0
	LD V3, 0
	LD V4, 2
	DRAW_SNAKE_BODY_LOOP:
		LD I, SNAKE_BODY_DATA
		ADD I, V3
		ADD I, V3         ; Add V3 twice because the equation is ith body = I + V3 * 2
		LD V1, [I]        ; Fills V0 through V4 with the saved data

		; 2. Draw the head using V0 (X) and V1 (Y)
		LD I, SPRITE_DOT
		DRW V0, V1, 1

		ADD V3, 1
		SE V3, V2
		JP DRAW_SNAKE_BODY_LOOP

	; 3. Draw the body (The Loop)
	RET

PLAY_BEEP:
	LD V0, 20
	LD ST, V0
	RET

STOP_GAME:
	CLS                  ; Clear the screen
	CALL PLAY_BEEP

	; ── Row 1: "GAME" at y=8 ──────────────────────────────────
	LD  V1, 8

	LD  V0, 20
	LD  I, SPR_GO_G
	DRW V0, V1, 8

	LD  V0, 26
	LD  I, SPR_GO_A
	DRW V0, V1, 8

	LD  V0, 32
	LD  I, SPR_GO_M
	DRW V0, V1, 8

	LD  V0, 38
	LD  I, SPR_GO_E
	DRW V0, V1, 8

	; ── Row 2: "OVER" at y=20 ─────────────────────────────────
	LD  V1, 20

	LD  V0, 20
	LD  I, SPR_GO_O
	DRW V0, V1, 8

	LD  V0, 26
	LD  I, SPR_GO_V
	DRW V0, V1, 8

	LD  V0, 32
	LD  I, SPR_GO_E
	DRW V0, V1, 8

	LD  V0, 38
	LD  I, SPR_GO_R
	DRW V0, V1, 8

	STOP_GAME_HALT:
		JP  STOP_GAME_HALT       ; Freeze here forever

	RET

; --- Data Section ---
; Aligning labels so they don't overlap
SPRITE_DOT:
	DB 0x80

; G
SPR_GO_G:
	DB 0x70
	DB 0x80
	DB 0x80
	DB 0xB8
	DB 0x88
	DB 0x78
	DB 0x00
	DB 0x00

; A
SPR_GO_A:
	DB 0x70
	DB 0x88
	DB 0x88
	DB 0xF8
	DB 0x88
	DB 0x88
	DB 0x00
	DB 0x00

; M
SPR_GO_M:
	DB 0x88
	DB 0xD8
	DB 0xA8
	DB 0x88
	DB 0x88
	DB 0x88
	DB 0x00
	DB 0x00

; E
SPR_GO_E:
	DB 0xF8
	DB 0x80
	DB 0x80
	DB 0xF0
	DB 0x80
	DB 0xF8
	DB 0x00
	DB 0x00

; O
SPR_GO_O:
	DB 0x70
	DB 0x88
	DB 0x88
	DB 0x88
	DB 0x88
	DB 0x70
	DB 0x00
	DB 0x00

; V
SPR_GO_V:
	DB 0x88
	DB 0x88
	DB 0x88
	DB 0x50
	DB 0x50
	DB 0x20
	DB 0x00
	DB 0x00

; R
SPR_GO_R:
	DB 0xF0
	DB 0x88
	DB 0x88
	DB 0xF0
	DB 0xA0
	DB 0x90
	DB 0x00
	DB 0x00

; We use enough space to store the snake's state
; LD [I], V4 needs 5 bytes of space (V0, V1, V2, V3, V4)
SNAKE_VEL_X:	 ; Stores Vel X (V2)
	DB 0x00
SNAKE_VEL_Y:	 ; Stores Vel Y (V3)
	DB 0x00
SNAKE_LEN:	     ; Stores Length (V4)
	DB 0x00

FOOD_X:
	DB 0x00
FOOD_Y:
	DB 0x00

SCORE_STORAGE:
	DB 0x00
	DB 0x00
	DB 0x00

SNAKE_BODY_DATA:
; Each snake body has 2 bytes for X and Y coordinates
; This doesn't include the snake head
__END:





















		
Game loop

Uses delay timer to create ~166ms tick. Waits for DT to reach zero before each move.

Initialization

Sets initial velocity (1, 0), length (4), and builds initial body by placing segments at decreasing X positions.

Input handling

Checks keys 2, 8, 4, 6. SKNP (skip if not pressed) is used - calls direction setter only if key is pressed.

Movement logic

The core: checks collision with body and food, handles wrapping, then shifts all body segments forward.

Eating food

When head matches food position: don't remove tail (snake grows), play beep, generate new food.

Game over

Clears screen, draws 'GAME OVER' text using 8×8 sprite data, then infinite loop to freeze.

Summary

  • Emulator: CPU Memory Display Keyboard Audio
  • Assembler: Lexer Parser Encoder
  • Example game: Snake with collision, wrapping, GAME OVER
  • Built with Go + SDL2