Snake - MOS 6502

Assembly Fun!

Check It Out On

About

This project is a minimalist implementation of the classic Snake game for the MOS 6502 microprocessor. Built entirely in assembly language, it demonstrates the fundamentals of game programming on limited hardware. The MOS 6502’s constrained environment provides a fascinating challenge, requiring creative approaches to memory and CPU cycle management.

The Challenge of Limited Hardware

The MOS 6502 is a relatively simple yet iconic 8-bit CPU with a 16-bit address bus capable of addressing up to 64KB of memory. It operates in a little-endian format, where multi-byte addresses are stored with the least significant byte first.
Despite its simplicity, this processor powered many classic systems, including the Commodore 64 and the NES.

Key architectural features

How It Works

The Snake game operates through a series of interconnected subroutines written in assembly language for the MOS 6502. Here, I’ll highlight and explain key sections of the code that illustrate the mechanics of the game, starting with its initialization and core loop.

Setting Up the Game State

.org $8000
LDX #$FF
TXS
start:
    JSR init
    JSR loop
init:
    LDA PLAYER_COLOR
    STA CENTER_ADDRESS
    LDA CENTER_VALUE
    STA PLAYER_HEAD
    LDA START_LENGTH
    STA PLAYER_LENGTH

Basic Setup

The initialization begins with .org $8000, a directive that tells the assembler to treat the start of the code as address $8000 in the memory map. This means the first instruction in the ROM, which is at address 0 in the physical file, will correspond to $8000 when loadedinto the system. This mapping is critical for ensuring that the program is placed in the correct region of memory for execution.

After setting up the ROM mapping, the LDX #$FF and TXS instructions initialize the stack pointer to $01FF, the top of the 6502’s fixed stack space. This ensures all stack operations (e.g., subroutine calls, temporary storage) function correctly as pushing to the stack will decrease the stack pointer.

Initialization

The start label marks the program’s entry point, leading into the init subroutine:
CENTER_ADDRESS - defining the grid’s central position on screen - is initialized to PLAYER_COLOR, the starting point for the Snake’s head.
PLAYER_HEAD stores the current position of the Snake’s head, and PLAYER_LENGTH initializes the Snake’s size to START_LENGTH.

By the time the program exits the init subroutine, all essential variables are prepared, and the game transitions to the main loop, ready for player interaction.

Preparing for Gameplay

After setting up initial game variables, the init subroutine enters a preparatory phase called init_loop, which ensures everything is ready before gameplay begins.

init_loop:
    JSR count_seed
    JSR get_direction
    JSR draw_snake
    LDA DIRECTION
    CMP #$00
    BEQ init_loop
    JSR set_new_apple
    RTS

The init_loop performs several key tasks repeatedly until the player provides input to start the game:

Seed Counting

The subroutine count_seed is called to increment the RANDOM_SEED variable. This variable is later used for generating random apple positions. Incrementing it continuously during initialization ensures the seed is dynamic, influenced by how long the player waits before starting.

Waiting for Player Input

The get_direction subroutine checks for input. If no input is detected, the game remains in the loop. This guarantees the player controls when the game starts and avoids the Snake moving unintentionally before the player is ready.

Drawing the Initial Snake

The draw_snake subroutine is called to render the Snake’s starting position on the screen. This ensures the player can see the Snake in its initial state.

Input Validation

The LDA DIRECTION and CMP #$00 instructions check if a direction key has been pressed. If no valid input is detected (BEQ init_loop), the loop continues. When input is detected, the game exits the loop.

Placing the First Apple

Once the player provides input, set_new_apple is called to randomly place the first apple on the grid using the dynamic RANDOM_SEED. This ensures that the game is immediately ready for its core mechanics: moving the Snake and collecting apples.

Transition to Gameplay

After initializing the apple and ensuring the Snake is drawn, the subroutine ends with RTS, transitioning control back to start which then calls the loop subroutine.

Why This Matters

The init_loop serves as a critical bridge between static initialization and dynamic gameplay:

This approach balances preparation and player control while optimizing the program’s logic for the constraints of the MOS 6502.

Game Loop: Real-Time Updates

loop:
    JSR count_seed
    JSR check_collisions
    JSR get_direction
    JSR check_do_update
    JSR do_body
    JSR do_movement
    JSR draw
    JMP loop

The core game loop calls a series of subroutines in sequence:

This structure ensures a predictable and modular execution flow.

Collision Detection: Apple and Self-Collisions

check_collisions:
    check_apple_collision:
        LDA APPLE_POSITION
        CMP PLAYER_HEAD
        BNE check_snake_collision
        INC PLAYER_LENGTH
        JSR set_new_apple
    check_snake_collision:
        LDA PLAYER_LENGTH
        CMP #$05
        BMI exit_subroutine
        LDX #$03
        snake_loop:
            LDA PLAYER_HEAD, X
            CMP PLAYER_HEAD
            BEQ collision_self
            INX
            CPX PLAYER_LENGTH
            BNE snake_loop
    RTS

The check_collisions routine determines whether the Snake collides with an apple or itself:

Apple Collision

Compares PLAYER_HEAD with APPLE_POSITION. If they match, the Snake grows by increasing PLAYER_LENGTH, and a new apple is placed.

Self-Collision

If the Snake’s length is 5 or more, the subroutine proceeds to check each body segment for a collision with the Snake’s head, iterating through the Snake’s body to detect overlap with its head and triggers the game_over routine if a collision is detected.
The collision check starts with the 4th element of the Snake because, at the time of the check, the body hasn’t yet been updated to follow the head’s new position.
This means the Snake’s head is actually one step ahead of the body in the current frame, so when comparing the head to the 4th element, it’s effectively checking for a collision with what will be the 5th body segment once the Snake moves. This ensures accurate detection despite the sequential update process.

Efficient Rendering: Drawing the Snake

draw:
    LDA BACKGROUND_COLOR
    STA VIDEO_OUT, Y
    JSR draw_apple
    JSR draw_snake
    RTS
draw_apple:
    LDA DIRECTION
    CMP #0
    BEQ draw_snake
    LDA APPLE_COLOR
    LDY APPLE_POSITION
    STA VIDEO_OUT, Y
    RTS
draw_snake:
    LDA PLAYER_COLOR
    LDY PLAYER_HEAD
    STA VIDEO_OUT, Y
    RTS

Rendering on limited hardware involves directly writing to memory-mapped video output.
The draw subroutine in this Snake implementation is optimized by updating only the Snake’s head and the last body segment, rather than refreshing the entire screen. The position of the last body segment is stored in the Y register as a side effect of the movement update, allowing it to be quickly set to the background color and erased from the screen. This eliminates the need to redraw the entire Snake body.

Once the old segment is cleared, the head is updated using the position stored in PLAYER_HEAD. By only modifying the head and the last segment, this approach minimizes CPU usage, making the game more efficient and responsive on the limited 6502 hardware. This method ensures smooth performance without unnecessary screen updates.

Optimizing Movement with Bitwise Operations

The do_movement subroutine determines the Snake’s movement direction based on the DIRECTION variable, calling the corresponding movement subroutine:

.define K_UP #$01
.define K_DOWN #$02
.define K_LEFT #$04
.define K_RIGHT #$08
...
do_movement:
    LDA DIRECTION
    AND K_UP
    BNE move_up
    LDA DIRECTION
    AND K_DOWN
    BNE move_down
    LDA DIRECTION
    AND K_LEFT
    BNE move_left
    LDA DIRECTION
    AND K_RIGHT
    BNE move_right
    RTS

This subroutine efficiently checks the current direction using bitwise AND operations against predefined constants (K_DOWN, K_RIGHT, etc.). When a match is found, it branches to the corresponding movement subroutine.

Movement subroutines (e.g., move_up, move_down) adjust the Snake’s position efficiently.

move_down Subroutine

move_down:
    CLC
    LDA PLAYER_HEAD
    CMP #$F0          
    BPL collision_wall
    CLC                 
    LDA PLAYER_HEAD
    ADC MOVE_VERTICAL  
    STA PLAYER_HEAD    
    RTS

The move_down subroutine updates the position of the Snake’s head by adding a vertical step (MOVE_VERTICAL). First, it checks if the head is near the bottom of the screen using CMP #$F0. If the head is near the boundary, it prevents the movement and triggers the collision handling by jumping to the collision_wall label. Otherwise, it clears the carry flag with CLC, adds the vertical step to PLAYER_HEAD using ADC, and stores the new position back in PLAYER_HEAD.
This results in the head moving down one row on the screen.

move_right Subroutine

move_right:
    LDA PLAYER_HEAD
    LDX #0
    right_loop:
        CPX #4
        BEQ collision_wall
        CLC
        ROR A
        INX
        BCS right_loop
    ; increase by 0x0001
    LDA PLAYER_HEAD
    ADC MOVE_HORIZONTAL
    STA PLAYER_HEAD
    RTS

In the move_right subroutine, the collision detection checks if the Snake’s head has reached the right edge by examining the first four bits of the head’s position.
The loop shifts the accumulator’s bits to the right using ROR, and each time a bit is shifted, it checks the carry flag.
If the carry is not set with one of the ROR operations it means that the head is not at the edge, and the subroutine safely moves the Snake right by adding 0x01 to its position.
On the other hand, if the carry is set, it means the shifted bit was 1, indicating the head might be near the right border. If the loop shifts all four bits, the X register reaches 4, confirming that the Snake is at the boundary.

Lessons Learned

Developing this naive implementation of Snake for the MOS 6502 underscored the ingenuity needed to write efficient assembly code on hardware with limited resources. The 8-bit architecture, minimal registers, and small addressable memory space required every instruction to be carefully planned for speed and efficiency.

Memory optimization was key, from using zero-page addressing for faster operations to ensuring stack stability.
Tasks like collision detection and rendering the Snake relied on clever approaches — such as updating only the head and erasing the tail — to minimize computation. Even seemingly simple logic, like checking if four bits are all set, demanded deliberate and efficient implementation.

This project showcased how working within tight hardware constraints pushes you to deeply understand the system and craft elegant, resourceful solutions.

Why I Loved Building This

This project is a tribute to the creativity and problem-solving of early programmers who achieved so much with so little. Writing Snake for the MOS 6502 has been an inspiring journey into the foundations of game programming, and I’m proud to showcase it as a testament to the ingenuity that defined an era — and continues to inspire today.