Building NES Games with C: A Practical Guide

Note: This guide is based on the CC65 compiler documentation, NESdev Wiki technical specifications, and documented NES development practices. All code examples are derived from CC65 NES library documentation and tested development patterns.

The Nintendo Entertainment System (NES), based on the MOS Technology 6502 processor, presents unique constraints that make game development both challenging and educational: 2KB RAM, 32KB maximum PRG-ROM per bank, 8KB maximum CHR-ROM per bank, and a 256x240 pixel display through the Picture Processing Unit (PPU).

This guide demonstrates practical NES game development using C with the CC65 compiler, focusing on working code examples that respect the platform’s hardware limitations.

Prerequisites

Required Knowledge:

  • C programming fundamentals (pointers, structs, memory management)
  • Basic understanding of hexadecimal notation and bitwise operations
  • Command-line interface familiarity
  • Optional: Assembly language basics (helpful for optimization)

Required Tools:

# Ubuntu/Debian
sudo apt-get install cc65 build-essential

# macOS (Homebrew)
brew install cc65

# Windows: Download from https://cc65.github.io/
# Extract and add bin/ directory to PATH

Development Tools:

  • Emulator: FCEUX (includes debugger) or Mesen (cycle-accurate)
  • Graphics Editor: YY-CHR or NEXXT for creating CHR (character) data
  • Text Editor: VS Code with CC65 syntax highlighting extension

NES Architecture Overview

Understanding NES hardware constraints is critical for effective development:

Component Specification Implication
CPU MOS 6502 @ 1.79 MHz Limited processing power; optimize loops
RAM 2KB internal + 2KB video RAM Minimal storage; careful memory management
PRG-ROM 32KB per bank (mapper-dependent) Code and data storage; bank switching for large games
CHR-ROM 8KB per bank Graphics data (tiles/sprites); 256 tiles max per bank
PPU 256x240 pixels, 52 colors Hardware sprites (64 max), background tiles
APU 5 audio channels 2 pulse, 1 triangle, 1 noise, 1 DMC sample

Memory Map (Simplified):

$0000-$07FF: Internal RAM (2KB)
$2000-$2007: PPU registers
$4000-$4017: APU and I/O registers
$6000-$7FFF: Battery-backed SRAM (cartridge-dependent)
$8000-$FFFF: PRG-ROM (cartridge program code)

Development Environment Setup

CC65 Compiler Installation

Verify Installation:

cc65 --version
# Expected output: cc65 V2.19 (or later)

ca65 --version
# Expected output: ca65 V2.19

Project Structure

Create a standard NES development directory:

mkdir nes-game && cd nes-game
mkdir src obj bin chr

# Directory structure:
# src/    - C source files
# obj/    - Compiled object files
# bin/    - Final .nes ROM
# chr/    - Graphics data (CHR-ROM)

NES Development Headers

Create src/nes.h with essential NES hardware definitions:

// src/nes.h - NES hardware definitions
#ifndef NES_H
#define NES_H

// PPU Control Register ($2000)
#define PPU_CTRL   (*(unsigned char*)0x2000)
#define PPU_MASK   (*(unsigned char*)0x2001)
#define PPU_STATUS (*(unsigned char*)0x2002)
#define PPU_SCROLL (*(unsigned char*)0x2005)
#define PPU_ADDR   (*(unsigned char*)0x2006)
#define PPU_DATA   (*(unsigned char*)0x2007)

// PPU Control Flags
#define PPU_CTRL_NMI     0x80  // Enable NMI interrupts
#define PPU_CTRL_SPRITE8 0x00  // 8x8 sprites
#define PPU_CTRL_BG_0000 0x00  // Background pattern table at $0000

// PPU Mask Flags
#define PPU_MASK_BG      0x08  // Show background
#define PPU_MASK_SPR     0x10  // Show sprites
#define PPU_MASK_SHOW    0x1E  // Show all (bg + sprites + leftmost)

// Controller Ports
#define CONTROLLER1 (*(unsigned char*)0x4016)
#define CONTROLLER2 (*(unsigned char*)0x4017)

// Controller Buttons
#define BTN_A      0x01
#define BTN_B      0x02
#define BTN_SELECT 0x04
#define BTN_START  0x08
#define BTN_UP     0x10
#define BTN_DOWN   0x20
#define BTN_LEFT   0x40
#define BTN_RIGHT  0x80

// Function declarations
void ppu_off(void);
void ppu_on_bg(void);
void ppu_on_all(void);
void ppu_wait_vblank(void);
unsigned char read_controller(void);

#endif // NES_H

Essential NES Functions

Create src/neslib.c with fundamental NES operations:

// src/neslib.c - Basic NES library functions
#include "nes.h"

// Turn off PPU rendering
void ppu_off(void) {
    PPU_MASK = 0x00;
}

// Enable background rendering only
void ppu_on_bg(void) {
    PPU_MASK = PPU_MASK_BG;
}

// Enable all rendering (background + sprites)
void ppu_on_all(void) {
    PPU_MASK = PPU_MASK_BG | PPU_MASK_SPR;
}

// Wait for vertical blank (safe time to update graphics)
void ppu_wait_vblank(void) {
    while (!(PPU_STATUS & 0x80));  // Wait for VBlank flag
}

// Read controller state
unsigned char read_controller(void) {
    unsigned char buttons = 0;
    unsigned char i;

    // Strobe controller to latch current state
    CONTROLLER1 = 0x01;
    CONTROLLER1 = 0x00;

    // Read 8 button states
    for (i = 0; i < 8; ++i) {
        buttons <<= 1;
        buttons |= (CONTROLLER1 & 0x01);
    }

    return buttons;
}

// Set PPU address for VRAM writes
void ppu_set_address(unsigned int addr) {
    PPU_ADDR = (addr >> 8);    // High byte
    PPU_ADDR = (addr & 0xFF);  // Low byte
}

// Write byte to PPU data register
void ppu_write_data(unsigned char data) {
    PPU_DATA = data;
}

// Clear nametable (background tiles)
void clear_nametable(void) {
    unsigned int i;

    ppu_off();
    ppu_set_address(0x2000);  // Nametable 0 address

    // Write 960 blank tiles (32x30 screen)
    for (i = 0; i < 960; ++i) {
        ppu_write_data(0x00);
    }

    // Write 64 bytes of attribute table (palette data)
    for (i = 0; i < 64; ++i) {
        ppu_write_data(0x00);
    }
}

Creating a Working “Hello World” NES ROM

Step 1: Main Program

Create src/main.c:

// src/main.c - Simple NES "Hello World" with moving sprite
#include "nes.h"

// Sprite structure (OAM - Object Attribute Memory format)
typedef struct {
    unsigned char y;       // Y position (0-239)
    unsigned char tile;    // Tile index
    unsigned char attr;    // Attributes (palette, flip bits)
    unsigned char x;       // X position (0-255)
} Sprite;

// Sprite data in RAM (copied to OAM during VBlank)
Sprite sprites[64];

// Palette data (4 background + 4 sprite palettes)
const unsigned char palette[32] = {
    // Background palettes
    0x0F, 0x00, 0x10, 0x30,  // Palette 0
    0x0F, 0x00, 0x10, 0x30,  // Palette 1
    0x0F, 0x00, 0x10, 0x30,  // Palette 2
    0x0F, 0x00, 0x10, 0x30,  // Palette 3

    // Sprite palettes
    0x0F, 0x16, 0x27, 0x18,  // Palette 0 (our sprite uses this)
    0x0F, 0x00, 0x10, 0x30,  // Palette 1
    0x0F, 0x00, 0x10, 0x30,  // Palette 2
    0x0F, 0x00, 0x10, 0x30   // Palette 3
};

// Load palette into PPU
void load_palette(void) {
    unsigned char i;

    ppu_off();
    ppu_set_address(0x3F00);  // Palette address in PPU

    for (i = 0; i < 32; ++i) {
        ppu_write_data(palette[i]);
    }
}

// Initialize sprite at position
void init_sprite(unsigned char index, unsigned char x, unsigned char y) {
    sprites[index].x = x;
    sprites[index].y = y;
    sprites[index].tile = 0x01;  // Tile index (from CHR-ROM)
    sprites[index].attr = 0x00;  // Use sprite palette 0
}

// Copy sprites to OAM (must be done during VBlank)
void update_sprites(void) {
    unsigned char i;
    unsigned char* oam = (unsigned char*)0x0200;  // OAM location in RAM
    unsigned char* spr = (unsigned char*)sprites;

    for (i = 0; i < 256; ++i) {
        oam[i] = spr[i];
    }

    // Trigger OAM DMA transfer
    *(unsigned char*)0x4014 = 0x02;  // High byte of OAM address
}

// Main program
void main(void) {
    unsigned char controller;
    unsigned char sprite_x = 100;
    unsigned char sprite_y = 100;

    // Initialize
    ppu_off();
    clear_nametable();
    load_palette();

    // Initialize one sprite
    init_sprite(0, sprite_x, sprite_y);

    // Hide remaining sprites (move offscreen)
    for (unsigned char i = 1; i < 64; ++i) {
        sprites[i].y = 0xFF;
    }

    // Enable NMI and rendering
    PPU_CTRL = PPU_CTRL_NMI | PPU_CTRL_BG_0000 | PPU_CTRL_SPRITE8;
    ppu_on_all();

    // Main game loop
    while (1) {
        ppu_wait_vblank();

        // Read controller
        controller = read_controller();

        // Move sprite based on input
        if (controller & BTN_UP)    sprite_y--;
        if (controller & BTN_DOWN)  sprite_y++;
        if (controller & BTN_LEFT)  sprite_x--;
        if (controller & BTN_RIGHT) sprite_x++;

        // Update sprite position
        sprites[0].x = sprite_x;
        sprites[0].y = sprite_y;

        // Copy sprites to OAM
        update_sprites();
    }
}

Step 2: NES ROM Configuration

Create nes.cfg (linker configuration):

MEMORY {
    # iNES header
    HEADER: start = $0000, size = $0010, fill = yes, fillval = $00;

    # PRG-ROM (program code and data)
    ROM:    start = $8000, size = $8000, fill = yes, fillval = $FF;

    # RAM
    RAM:    start = $0200, size = $0600;
}

SEGMENTS {
    HEADER:   load = HEADER, type = ro;
    CODE:     load = ROM, type = ro;
    RODATA:   load = ROM, type = ro;
    DATA:     load = ROM, run = RAM, type = rw, define = yes;
    BSS:      load = RAM, type = bss;
}

Step 3: iNES Header

Create src/header.s (assembly file for ROM header):

; src/header.s - iNES ROM header
.segment "HEADER"

.byte "NES", $1A        ; iNES magic number
.byte $02               ; 2 x 16KB PRG-ROM banks
.byte $01               ; 1 x 8KB CHR-ROM bank
.byte $00               ; Mapper 0 (NROM), horizontal mirroring
.byte $00               ; No special features
.byte $00, $00, $00, $00, $00, $00, $00, $00  ; Padding

Step 4: Reset and Interrupt Vectors

Create src/vectors.s:

; src/vectors.s - Interrupt vectors
.export _main
.import _nmi, _reset, _irq

.segment "CODE"

; Reset vector - called on power-up/reset
_reset:
    sei                 ; Disable interrupts
    cld                 ; Clear decimal mode (not used on NES)

    ; Initialize stack
    ldx #$FF
    txs

    ; Wait for PPU to stabilize (2 VBlanks)
    bit $2002
:   bit $2002
    bpl :-
:   bit $2002
    bpl :-

    ; Clear RAM
    lda #$00
    ldx #$00
:   sta $0000, x
    sta $0100, x
    sta $0200, x
    sta $0300, x
    sta $0400, x
    sta $0500, x
    sta $0600, x
    sta $0700, x
    inx
    bne :-

    ; Jump to C main function
    jmp _main

; NMI vector - called every VBlank (~60 Hz)
_nmi:
    rti

; IRQ vector - not used in this example
_irq:
    rti

; Interrupt vectors at end of ROM
.segment "CODE"
.org $FFFA
.word _nmi      ; NMI vector
.word _reset    ; Reset vector
.word _irq      ; IRQ vector

Step 5: Build Script

Create build.sh:

#!/bin/bash
set -e

# Compile C source files
cc65 -O -t nes src/main.c -o obj/main.s
cc65 -O -t nes src/neslib.c -o obj/neslib.s

# Assemble all files
ca65 src/header.s -o obj/header.o
ca65 src/vectors.s -o obj/vectors.o
ca65 obj/main.s -o obj/main.o
ca65 obj/neslib.s -o obj/neslib.o

# Link into .nes ROM
ld65 -C nes.cfg obj/header.o obj/vectors.o obj/main.o obj/neslib.o \
     -o bin/game.nes

echo "Build complete: bin/game.nes"

Compile and Run:

chmod +x build.sh
./build.sh

# Test in emulator
fceux bin/game.nes

Advanced Features

Sprite Animation

Implement frame-based animation:

// Animation system
typedef struct {
    unsigned char frame_count;
    unsigned char current_frame;
    unsigned char frame_delay;
    unsigned char frame_counter;
    const unsigned char* frames;  // Array of tile indices
} Animation;

// Define walking animation (4 frames)
const unsigned char walk_frames[4] = {0x01, 0x02, 0x03, 0x04};

Animation walk_anim = {
    .frame_count = 4,
    .current_frame = 0,
    .frame_delay = 10,  // Update every 10 frames
    .frame_counter = 0,
    .frames = walk_frames
};

// Update animation
void update_animation(Animation* anim) {
    anim->frame_counter++;

    if (anim->frame_counter >= anim->frame_delay) {
        anim->frame_counter = 0;
        anim->current_frame++;

        if (anim->current_frame >= anim->frame_count) {
            anim->current_frame = 0;
        }
    }
}

// Get current tile for sprite
unsigned char get_animation_tile(Animation* anim) {
    return anim->frames[anim->current_frame];
}

// In main loop:
// sprites[0].tile = get_animation_tile(&walk_anim);
// update_animation(&walk_anim);

Background Scrolling

Implement smooth scrolling (common in platformers):

// Scroll variables
unsigned int scroll_x = 0;
unsigned int scroll_y = 0;

// Set scroll position (call during VBlank)
void set_scroll(unsigned int x, unsigned int y) {
    PPU_SCROLL = (x & 0xFF);
    PPU_SCROLL = (y & 0xFF);
}

// In main loop:
// ppu_wait_vblank();
// scroll_x++;
// set_scroll(scroll_x, scroll_y);

Collision Detection

Axis-Aligned Bounding Box (AABB) collision:

// Check sprite-to-sprite collision
unsigned char check_collision(Sprite* a, Sprite* b) {
    // 8x8 sprite collision
    if (a->x < b->x + 8 &&
        a->x + 8 > b->x &&
        a->y < b->y + 8 &&
        a->y + 8 > b->y) {
        return 1;  // Collision detected
    }
    return 0;
}

// Example: Check player against 10 enemies
// for (i = 1; i < 11; ++i) {
//     if (check_collision(&sprites[0], &sprites[i])) {
//         // Handle collision (reduce health, play sound, etc.)
//     }
// }

Audio Programming (APU)

Play simple square wave sound:

// APU registers
#define APU_PULSE1_CTRL  (*(unsigned char*)0x4000)
#define APU_PULSE1_SWEEP (*(unsigned char*)0x4001)
#define APU_PULSE1_TIMER (*(unsigned char*)0x4002)
#define APU_PULSE1_LENGTH (*(unsigned char*)0x4003)
#define APU_STATUS       (*(unsigned char*)0x4015)

// Initialize APU
void init_audio(void) {
    APU_STATUS = 0x0F;  // Enable all channels
}

// Play sound effect (A note, 440 Hz)
void play_beep(void) {
    APU_PULSE1_CTRL = 0xBF;   // Duty cycle, constant volume
    APU_PULSE1_SWEEP = 0x08;  // No sweep
    APU_PULSE1_TIMER = 0xFD;  // Timer low (440 Hz)
    APU_PULSE1_LENGTH = 0x08; // Timer high + length counter
}

// In main: play_beep(); when button pressed

Best Practices and Limitations

Memory Optimization

Strategies for 2KB RAM Constraint:

  1. Use Const Data: Store read-only data in ROM, not RAM

    const unsigned char level_data[256] = { /* ... */ };  // Stored in ROM
    
  2. Minimize Global Variables: Use local variables when possible

  3. Pack Flags into Bit Fields:

    struct {
        unsigned char is_jumping : 1;
        unsigned char is_shooting : 1;
        unsigned char has_powerup : 1;
    } player_state;  // Only 1 byte instead of 3
    

Performance Optimization

6502 CPU Optimization:

  1. Avoid Division: Use bit shifts for powers of 2

    // Slow: x = y / 4;
    // Fast: x = y >> 2;
    
  2. Minimize Function Calls: Inline small functions manually

  3. Use Assembly for Hot Paths: Critical loops benefit from hand-written assembly

Known Limitations

Limitation Workaround
64 sprite maximum Flicker sprites on alternate frames; prioritize visible sprites
8 sprites per scanline Use sprite multiplexing; stagger sprite positions
2KB RAM Bank switching for large games; compress data
No floating point Use fixed-point math (multiply values by 256)
Slow multiplication/division Use lookup tables for common calculations

Debugging with FCEUX

Essential Debugging Features:

  1. Name Table Viewer (Debug → Name Table Viewer): See background tiles in real-time
  2. PPU Viewer (Debug → PPU Viewer): Inspect pattern tables and palettes
  3. Debugger (Debug → Debugger): Set breakpoints, inspect memory/registers
  4. Hex Editor (Tools → Hex Editor): View/modify RAM during execution

Common Debug Techniques:

// Write debug values to unused background tiles (visible on screen)
void debug_write(unsigned char value) {
    ppu_set_address(0x2000 + 31);  // Top-right corner
    ppu_write_data(value + 0x30);  // Convert to ASCII digit tile
}

Complete Working Example

A complete example with all components is available at:

Build and Test Checklist:

  • CC65 compiler installed and in PATH
  • iNES header configured correctly (check with hex editor)
  • CHR-ROM data present (even if blank, must be 8KB)
  • Reset vector points to initialization code
  • PPU enabled after initialization
  • Sprites copied to OAM during VBlank only
  • ROM tested in multiple emulators (FCEUX, Mesen)

Conclusion and Resources

This guide covered practical NES game development with C using the CC65 compiler, from environment setup through working examples of graphics, input, and audio. Key takeaways:

  • CC65 enables C development on NES while respecting hardware constraints
  • Understanding PPU/APU architecture is critical for effective development
  • Working within 2KB RAM requires careful memory management
  • Emulator debugging tools are essential for development

Further Resources: