The C64 had many things going for it that made it the best-selling home micro of the 1980s, lasting into the 1990s, but I think the inclusion of hardware sprites was crucial in it being seen as a games machine, and garnering support from commercial game developers.
It’s a big enough deal that I thought I would sit down and write about how they work under the hood, and how we as modern retro game developers in the current year and beyond can use them in our games, especially now I have added XC-BASIC and UGBASIC to my online IDE!
No matter which language or toolchain you use, all C64 sprites are handled behind the scenes by the VIC-II graphics chip. BASIC, XC-BASIC3 and ugbasic are all just different ways of configuring the same hardware.
C64 Sprite Definitions
Creating sprites can be done easily using an online tool such as petscii.krissz.hu (yep, it does sprites as well as PETSCII) or a desktop tool such as Spritepad.
But how is the data structured, and how is that translated into what you see on screen?
A C64 hardware sprite has a fixed format:
- 24 pixels wide
- 21 pixels tall
- Stored as exactly 64 bytes in RAM
The VIC-II does not know about variables, arrays, DATA statements, or images. It only knows how to fetch 64 bytes from the specified area in memory and render them into pixels on screen every video frame.
To do that, it relies on two things:
- Sprite pointers
- The currently active VIC memory bank
Understanding those two ideas explains almost every sprite bug you will ever hit on the C64.

You can now follow the tutorials and edit the code right in your web browser with the Online Retro IDE
– No downloads, configuration, etc necessary, and it is free!
Sprite pointers Versus Sprites in Memory
I have to admit, memory addressing is a key area I often get confused. Math is not my strong suit, but as you will see the C64 makes the math even more ‘fun’ than usual.
The VIC-II has eight sprite pointer registers located at 2040-2047
One byte per sprite.
Each pointer does not store an address. Instead, it stores a block number. A block is always 64 bytes long.
The rule is:
sprite address = pointer value × 64
So if you write:
POKE 2040,192
The VIC-II will fetch sprite 0 data starting at:
192 × 64 = 12288 ($3000)
From that address it reads 64 bytes, once per frame, forever.
There is no safety checking. If anything else writes to that memory, the sprite instantly changes shape.
This is why sprite corruption often looks random. The VIC-II is faithfully displaying whatever trash data happens to be there.
VIC Memory and Banking
The C64 has 64 KB of RAM, but the VIC-II can only “see” 16 KB of it at any one time. Which 16 KB it sees is controlled by a hardware register in the CIA chip.
These 16 KB regions are called VIC banks:
| Bank | Address range |
|---|---|
| 0 | $0000 to $3FFF |
| 1 | $4000 to $7FFF |
| 2 | $8000 to $BFFF |
| 3 | $C000 to $FFFF |
By default, the machine starts in bank 0. That means all sprite pointers are relative to $0000–$3FFF.
Within that range, some areas are dangerous:
$0400to$07E7is screen memory- Parts of low memory are used by BASIC and the OS
If you place sprite data in or around screen memory, it is likely to be overwritten as soon as you add text.
This is exactly what happened in my early versions of the XC-BASIC3 example below. The first sprite landed just below screen RAM and survived. The second sprite landed exactly at $0400 and was immediately overwritten by screen output!
Moving the sprite data to $3000 fixed the problem:
- It is inside the active VIC bank
- It is not used by screen RAM
- It is not used by BASIC variables
This rule applies no matter what language you use. In a battle between you and the hardware, the VIC-II always wins.
Multicolour Sprites
In single-color mode, each sprite pixel is 1 bit. One byte represents 8 pixels. Easy.
In multicolor mode, each pixel is 2 bits. That changes everything:
- Each byte now represents 4 pixels
- Pixels are twice as wide on screen
- Each sprite row is exactly 3 bytes
- 21 rows × 3 bytes = 63 bytes
- Plus 1 unused padding byte = 64 bytes total
Each 2-bit pixel value means:
- 00 transparent
- 01 sprite’s own specific colour
- 10 colour from multicolour register
$D025 - 11 colour from multicolour register
$D026
Yeah, the C64 was a lot more limited when it came to coloured graphics than some competitors without employing some sneaky tricks. It makes me respect the pixel artists and demo coders who push these limits even more.
If multicolour mode is not enabled in the VIC-II register $D01C, the same data is interpreted as single-colour bits, producing vertical stripes and corrupted shapes.
VIC-II Memory Map
(Bank 0, default)
$0000 ───────────────────────────────────────────
Zero page, system vectors, OS work areas
(used by KERNAL, BASIC, IRQs, etc.)
$0200 ───────────────────────────────────────────
BASIC workspace, variables, program data
(XC-BASIC program image also lives around here)
$03C0 ───────────────────────────────────────────
Often used in simple demos for ONE sprite
(safe only if nothing else grows into it)
$0400 ─────────────────────────────────────────── ← SCREEN RAM START
Screen character matrix (40 × 25 = 1000 bytes)
PRINT, TEXTAT, cursor movement all write here
$07E8 ─────────────────────────────────────────── ← SCREEN RAM END
Colour RAM lives elsewhere ($D800+)
$0800 ───────────────────────────────────────────
Free / mixed use depending on program
(easy to collide with BASIC or XC-BASIC data)
$1000 ───────────────────────────────────────────
More free RAM, still risky without planning
$2000 ───────────────────────────────────────────
Common location for graphics or character sets
(still inside VIC bank 0)
$3000 ─────────────────────────────────────────── ← SAFE SPRITE DATA
Recommended sprite storage
• inside VIC bank
• not screen RAM
• not used by BASIC
Sprite 0 @ $3000 (pointer = 192)
Sprite 1 @ $3040 (pointer = 193)
Sprite 2 @ $3080 (pointer = 194)
etc.
$3FFF ─────────────────────────────────────────── ← VIC BANK 0 END
Sprites in Regular C64 BASIC
In C64 BASIC, everything is explicit:
- Sprite data is manually
POKEd into memory - Sprite pointers are manually set
- Sprite positions are manually updated
- Memory placement is entirely your responsibility
Because BASIC has no compiler, the DATA statements are read at runtime and written directly into RAM. That makes it easier to see what is happening, but also easier to overwrite things accidentally.
The program works because it carefully places sprite data at pointer 192, which is $3000 (12288 decimal) and nearby addresses and continues to set the other pointers.
C64 BASIC Code

Here is code to display and move my tribute to the original Taito Space Invader on screen.
Check out a full playable demo online in your web browser.
0 REM ==================
1 REM SINGLE SPRITE DEMO
2 REM ==================
3 REM
4 REM CLEAR SCREEN/BORDER (BLACK), TEXT WHITE
5 POKE 53280,0: POKE 53281,0: POKE 646, 1: PRINT CHR$(147)
6 REM SPRITE COLOR = GREEN
10 POKE 53287, 5
11 POKE 53248+21, 1 : REM enable sprite 0
12 POKE 2040,192 : REM sprite 0 data slot
19 REM LOAD DATA
20 FOR N = 0 TO 62: READ B: POKE 12288+N,B: NEXT
30 FOR X = 0 TO 255: Y=100 + INT(16 * SIN(X / 3.14))
40 POKE 53248,X: REM INVADER X COORDINATE
50 POKE 53249,Y: REM INVADER Y COORDINATE
60 NEXT X
70 GOTO 30
1000 REM SPRITE 0
1001 DATA 0,0,0
1002 DATA 6,0,96
1003 DATA 6,0,96
1004 DATA 1,129,128
1005 DATA 1,129,128
1006 DATA 7,255,224
1007 DATA 7,255,224
1008 DATA 30,126,120
1009 DATA 30,126,120
1010 DATA 127,255,254
1011 DATA 127,255,254
1012 DATA 103,255,230
1013 DATA 103,255,230
1014 DATA 102,0,102
1015 DATA 102,0,102
1016 DATA 1,231,128
1017 DATA 1,231,128
1018 DATA 0,0,0
1019 DATA 0,0,0
1020 DATA 0,0,0
1021 DATA 0,0,0
How things Change in XC-BASIC3
XC-BASIC3 is compiled, not interpreted. That changes where sprite data starts life.
When we write the following in the code below:
ballsprite:
DATA AS BYTE ...
Those bytes are stored inside the compiled program data, not yet in sprite memory, so our VIC-II cannot see them there. That is why we must do:
memcpy @ballsprite, destination, 64
The @ballsprite syntax provides the address of the data inside the program. memcpy then copies it into VIC-visible RAM where the sprite pointer can reach it.
XC-BASIC3 helps with sprite positioning and colour setup, but it does not manage memory layout for you. You still have to:
- Choose a safe address
- Stay inside the active VIC bank
- Set the sprite pointers yourself
- Enable multicolor mode in hardware
This is closer to low-level C64 programming than it first appears, which makes XC-BASIC3 a good bridge between BASIC and assembly.
XC-BASIC3 Bat and Ball Game

This is a bat and ball game in the style of the classic Pong. Use WASD for the keys. We have multicolour sprites for the ball and a bat, and if you miss the ball it will bounce off the edges of the playable screen area.
Check out the code and have a play in the online IDE.
REM constants for key scan codes and initializations
CONST W = 64770 ' scan code
CONST A = 64772 ' scan code
CONST S = 64800 ' scan code
CONST D = 64260 ' scan code
CONST ESC = 32640 ' scan code
DIM a$ AS STRING * 1
DIM x,y AS BYTE FAST
DIM ballx,bally AS BYTE FAST
DIM xdir,ydir AS INT FAST
DIM cx,cy AS BYTE
REM screen colours
BACKGROUND 0
BORDER 0
PRINT CHR$(147)
' Wall/HUD background
FOR cy = 0 TO 24
FOR cx = 30 TO 39
TEXTAT cx, cy, CHR$(230), 5
NEXT cx
NEXT cy
REM store sprite shapes in safe VIC-visible RAM
CONST SHAPES_START = $3000 : REM 12288
memcpy @ballsprite, SHAPES_START, 64
memcpy @batsprite, SHAPES_START+64, 64
REM compiler multicolor flags
SPRITE MULTICOLOR 1, 1
REM multicolor settings from sprite editor
POKE $D025, 12 : REM multicolor #1
POKE $D026, 11 : REM multicolor #2
REM set sprite colors and placement
SPRITE 0 COLOR 15 MULTI ON BACKGROUND
SPRITE 1 COLOR 15 MULTI ON BACKGROUND
REM hardware multicolor enable (sprites 0 and 1)
POKE $D01C, %00000011
REM allow keys to repeat when held down
POKE 650,128
REM title text
TEXTAT 5,10, "bat and ball demo", 1
TEXTAT 5,12, " keys w, a, s, d", 1
REM set sprite pointers for $3000 and $3000+64
POKE 2040, SHAPES_START/64 : REM = 192
POKE 2041, (SHAPES_START+64)/64 : REM = 193
REM initial positions and directions
x=100
y=110
ballx=70
bally=70
xdir=-1
ydir=-1
SPRITE 1 ON AT x,y
SPRITE 0 ON AT 120,120
SPRITE CLEAR HIT
REM Keyboard control and game loop
DO WHILE NOT KEY(ESC)
REM check keys and move bat
IF KEY(A) THEN x=x-2
IF KEY(D) THEN x=x+2
IF KEY(W) THEN y=y-2
IF KEY(S) THEN y=y+2
REM bounce ball off bat and walls
IF (bally+10) > y AND ballx >= x-8 AND ballx <= x+8 AND ydir>0 THEN ydir=-ydir
IF ballx=240 THEN xdir=-xdir
IF ballx=0 THEN xdir=-xdir
IF bally=240 THEN ydir=-ydir
IF bally=40 THEN ydir=-ydir
REM update ball position
ballx=ballx+xdir
bally=bally+ydir
REM draw sprites at new positions after raster wait
WAIT 53265, 128
SPRITE 1 AT x,y
SPRITE 0 AT ballx,bally
LOOP
END
REM sprite data exported from sprite editor
ballsprite:
DATA AS BYTE _
0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,21,0, _
0,85,64, 1,105,80, 1,169,80, 5,169,84, _
5,165,244, 5,85,244, 5,87,244, 1,87,208, _
1,223,80, 0,125,64, 0,21,0, _
0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0
batsprite:
DATA AS BYTE _
0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0, _
0,0,0, 0,0,0, _
5,85,80, _
26,170,164, _
105,85,91, _
101,85,87, _
85,85,87, _
101,221,223, _
87,119,127, _
63,255,252, _
15,255,240, _
0,0,0, 0,0,0, 0,0,0, 0,0,0, 0,0,0, 0 : REM final padding byte so total = 64
Enter UGBASIC
ugbasic lives at a higher, more abstracted level than XC-BASIC.
Instead of asking you where sprite memory lives, it works in terms of image files, atlases and sprites. Internally, it still:
- Allocates sprite memory
- Copies pixel data into RAM
- Sets sprite pointers
- Configures VIC registers
But it does much of this for you.
That makes ugbasic more abstracted and potentially faster to get results with, plus it makes it harder to accidentally corrupt memory. The trade-off is that the memory model is mostly hidden, so you do not see the direct relationship between sprite data, pointers and VIC banks unless you go looking for it.
Many retro developers like to micro (heh) manage data usage to squeeze every last available byte, so keep that in mind.
All three approaches ultimately drive the same eight hardware sprites, however. The difference is how much of the VIC-II you are asked to understand up front.
UGBASIC Christmas Demo
More festive fun with another Christmas themed sprite demo (again, click through to see it running in the online IDE). Can you guess how the final game I am developing using these sprites will work? …
BITMAP ENABLE(2)
DEFINE MSPRITE ASYNC
COLOR BORDER WHITE
CLS WHITE
xmas := LOAD IMAGE("xmas.png")
snowpic := LOAD IMAGE("snow.png")
elfpic := LOAD IMAGE("elf.png") EXACT
CONST limitx = SCREEN WIDTH - SPRITE WIDTH
RANDOMIZE
FOR idx = 1 TO 25
x=RND(320-IMAGE WIDTH(snowpic))
y=RND(75)+75
PUT IMAGE snowpic AT x,y WITH TRANSPARENCY
NEXT
PUT IMAGE elfpic AT 200, 50 WITH TRANSPARENCY
PUT IMAGE xmas AT 80, 0 WITH TRANSPARENCY
sprites := LOAD ATLAS("xmas_sprites2.png") FRAME SIZE (24 , 24) EXACT
sprite1 = MSPRITE( IMAGE( sprites FRAME 1 ) )
sprite2 = MSPRITE( IMAGE( sprites FRAME 2 ) )
sprite3 = MSPRITE( IMAGE( sprites FRAME 4 ) )
DO
SPRITE sprite1 ENABLE AT elfx+SCREEN BORDER X, 21+elfy+SCREEN BORDER Y
SPRITE sprite2 ENABLE AT SCREEN BORDER X+limitx-elfx, 60+elfy+SCREEN BORDER Y
SPRITE sprite3 ENABLE AT SCREEN BORDER X+elfx, 90+elfy+SCREEN BORDER Y
elfx=elfx+1
IF elfx > limitx THEN
elfx = 0
ENDIF
MSPRITE UPDATE
LOOP


ZX Spectrum Next on the Internet: Xberry Pi ESP01 and Pi Zero Upgrades