The Art of CHIP-8
CHIP-8
is a programming language originally developed for the 1977
COSMAC VIP
kit computer. CHIP-8 programs are composed of a series of two-byte instructions resembling machine-code for a simple virtual instruction-set architecture, so CHIP-8 interpreters are often also referred to as “emulators”. Indeed, writing a CHIP-8 interpreter is an excellent way to learn the principles underlying emulators for antique computers and game consoles, and as a result there are a dizzying array of
thousands
of CHIP-8 runtimes available for almost every conceivable platform.
For historical platforms to
live
, rather than simply be
preserved
, we must write new software for them. The profusion of CHIP-8 implementations comes in turn with a great deal of
confusion
, as a half-century-long game of telephone has produced a wide variety of diverging behaviors in interpreters. Over the course of developing
Octo
, my high-level CHIP-8 assembler, I helped popularize and standardize a variety of “quirks flags” which capture common divergences between extant CHIP-8 flavors, and investigated many dark, unspecified corners of influential interpreters. There are now
mature test suites
available for CHIP-8 interpreters and their variants, so there’s no excuse for modern interpreters to get the details wrong. Still, the reality of CHIP-8 in the wild is fragmented: many interpreters for obscure platforms are written by beginners unaware of any broader hobbyist community and abandoned as soon as they (appear to) correctly run
PONG.CH8
.
In this article I will examine CHIP-8 as an instruction set and its practical implications for writing new programs, distilling a number of scattered tutorials, examples, and FAQs I’ve written in the past. I will specifically point out approaches which are portable across all but the buggiest and least complete existing CHIP-8 interpreters. Example code will use Octo’s notation; this document is not intended as a complete reference manual for Octo assembly language, but I will endeavor to explain new ideas as we encounter them.
Index
System Overview
CHIP-8 operates in a 12-bit address space. The original CHIP-8 interpreter resided in the first 512 bytes of this space, with programs starting at address
0x200
. It
also
reserved some of the upper region of the address space for a stack, a framebuffer, and several scratchpads. As a result, we are left with a maximum of 3232 bytes for our code and data. Modern CHIP-8 interpreters are often more generous, leaving up to 3584 bytes for user programs, and they will often use the low 512 bytes of memory to store their hex font(s) or nothing at all, leaving it available for programs to manipulate.
Your code and data should fit within 3232 bytes for maximum portability.
We have a file of 16 general-purpose 8-bit registers named
v0
-
vf
, giving the platform a pleasantly RISC-ey feel. The 12-bit
1
“index register”
i
is used for all operations which reference or manipulate memory. There is an internal stack for threading subroutine return addresses, but it is
opaque
: the instruction set does not allow programs to freely push or pop temporary values or inspect the contents of the stack.
Programs take input from a hexadecimal keypad. For output, we have a 64x32 pixel 1-bit bitmapped display and a simple piezo
buzzer
for making noise. We are also afforded a non-interrupting
delay
timer and a random number generator.
There are 34 elementary CHIP-8 instructions. In the descriptions below,
vx
and
vy
are any v-register, and
NNN
,
NN
, and
N
represent an immediate 12-bit, 8-bit, or 4-bit value, respectively:
|
Machine Code
|
Octo Syntax
|
Notes
|
00E0
|
clear
|
Clear the display.
|
00EE
|
;
or
return
|
Exit a subroutine.
|
1NNN
|
jump NNN
|
|
2NNN
|
NNN
or
:call NNN
|
Call a subroutine.
|
3XNN
|
if vx != NN then
|
Conditional skip.
|
4XNN
|
if vx == NN then
|
Conditional skip.
|
5XY0
|
if vx != vy then
|
Conditional skip.
|
6XNN
|
vx := NN
|
|
7XNN
|
vx += NN
|
|
8XY0
|
vx := vy
|
|
8XY1
|
vx |= vy
|
Bitwise
OR
.
|
8XY2
|
vx &= vy
|
Bitwise
AND
.
|
8XY3
|
vx ^= vy
|
Bitwise
XOR
.
|
8XY4
|
vx += vy
|
vf
gets 1 on carry, otherwise 0.
|
8XY5
|
vx -= vy
|
vf
gets 0 on borrow, otherwise 1.
|
8XY6
|
vx >>= vy
|
vf
gets old least significant bit.
|
8XY7
|
vx =- vy
|
vf
gets 0 on borrow, otherwise 1.
|
8XYE
|
vx <<= vy
|
vf
gets old most significant bit.
|
9XY0
|
if vx == vy then
|
Conditional skip.
|
ANNN
|
i := NNN
|
|
BNNN
|
jump0 NNN
|
Jump to address
NNN
+
v0
.
|
CXNN
|
vx := random NN
|
Random byte bitwise
AND
ed with
NN
.
|
DXYN
|
sprite vx vy N
|
Draw on display;
vf
gets 1 on collision, otherwise 0.
|
EX9E
|
if vx -key then
|
Is a key not pressed?
|
EXA1
|
if vx key then
|
Is a key pressed?
|
FX07
|
vx := delay
|
|
FX0A
|
vx := key
|
Wait for a keypress.
|
FX15
|
delay := vx
|
|
FX18
|
buzzer := vx
|
|
FX1E
|
i += vx
|
|
FX29
|
i := hex vx
|
Set
i
to a hex character sprite.
|
FX33
|
bcd vx
|
Decode
vx
into binary-coded decimal.
|
FX55
|
save vx
|
Save
v0
-
vx
to memory address
i
through
i
+x.
|
FX65
|
load vx
|
Load
v0
-
vx
from memory address
i
through
i
+x.
|
Observe that all instructions are two bytes wide, and component fields are nybble-aligned: these characteristics facilitate hand-assembling programs with a pen and paper and also help simplify writing some forms of self-modifying code. The following sections will discuss these instructions in more detail, grouped by their functional purpose.
Arithmetic Instructions
Two arithmetic instructions take immediate arguments:
|
Machine Code
|
Octo Syntax
|
6XNN
|
vx := NN
|
7XNN
|
vx += NN
|
The remainder manipulate two registers, with
vx
storing the result.
|
Machine Code
|
Octo Syntax
|
8XY0
|
vx := vy
|
8XY1
|
vx |= vy
|
8XY2
|
vx &= vy
|
8XY3
|
vx ^= vy
|
8XY4
|
vx += vy
|
8XY5
|
vx -= vy
|
8XY6
|
vx >>= vy
|
8XY7
|
vx =- vy
|
8XYE
|
vx <<= vy
|
Octo uses syntax similar to C-family languages for these operations, with a Pascal-style
:=
assignment operator for symmetry. The
^=
=-
<<=
and
>>=
instructions were present in the original CHIP-8 interpreter, but not documented; they arose as a natural consequence of the RCA-1802 instruction encoding. The
=-
instruction is like
-=
except it subtracts
vx
from
vy
instead of
vy
from
vx
; as always the result is stored in
vx
.
The shift instructions are intended to set
vx
to
vy
shifted left or right by 1 place, storing the shifted-out bit in
vf
. Many modern interpreters incorrectly implement these instructions as ignoring
vy
and shifting
vx
in-place.
Only use shift instructions of the form
vx <<= vx
and
vx >>= vx
. Using the same register for both arguments will produce consistent behavior with or without the "shift quirks".
In some interpreters, the bitwise operations
|=
,
&=
, and
^=
modify
vf
as a side effect. This problem is less well-known than the “shift quirks”, and can be quite a surprise when trying to run programs on an emulated COSMAC VIP.
Assume the bitwise instructions
vx |= vy
,
vx &= vy
, and
vx ^= vy
destroy the contents of
vf
.
If you need to do a bitwise
NOT
, you can use
XOR
with an appropriate constant in a register. The
vf
register is an ideal choice if you only need that constant once:
vf := 0xFF
v0 ^= vf # invert the bits in v0
Many interpreters are inconsistent as to whether they write a carry-flag result
before
or
after
the main result of an arithmetic instruction, leading to ambiguity if the destination register (
vx
) is
vf
.
Never use the
vf
register as the destination of arithmetic instructions except
vf := NN
,
vf += NN
, or
vf := vx
.
If you ever need a “no-op” instruction, the best options are instructions of the form
vx := vx
, such as
v0 := v0
. Instructions of the form
vx += 0
work, too; this leads to an important potential “gotcha”. Say you’re trying to increment a 16-bit counter:
v0 += 1 # increment low byte
v1 += vf # carry into the high byte
The behavior of that snippet is
undefined
, because adding an immediate value to a v-register does not alter the carry flag
vf
. The fact that the original version may appear to work, sometimes- depending on whatever happened to already be in
vf
- is all the more infuriating. You meant to do this instead:
vf := 1 # put 1 in a temporary register
v0 += vf # increment low byte with our constant 1
v1 += vf # carry into the high byte
Octo lets you use negative numbers for literals, which are interpreted as their
two’s complement
equivalents. Whether you use this feature or not, remember that
vx += NN
can be used both for
incrementing
and (via overflow)
decrementing
v-registers:
v0 += -1
v0 += 255
Taking the bitwise
OR
of N with N+1 will have the effect of setting the least significant (or rightmost) zero bit in the byte:
vf := v0
v0 += 1
v0 |= vf
Similarly, the bitwise
AND
of N with N-1 will clear the least significant (or rightmost) one bit in the byte:
vf := v0
v0 += -1
v0 &= vf
If a (nonzero) number becomes zero after performing this operation you know it had exactly one bit set and was thus a power of two.
Memory
Now we’re ready to discuss manipulating the index register
i
and using it to access memory:
|
Machine Code
|
Octo Syntax
|
ANNN
|
i := NNN
|
FX1E
|
i += vx
|
FX29
|
i := hex vx
|
FX33
|
bcd vx
|
FX55
|
save vx
|
FX65
|
load vx
|
The first thing you’re likely to notice about these instructions is that our ability to modify the
i
register is constrained: we can set it, and we can advance it with
i += vx
, but we cannot decrement it, and there’s no provision for reading it out and stashing it for later.
The
save
and
load
instructions are also a bit unusual: they write or read a
range
of bytes from memory, starting at
i
. Let’s say we want to stash the contents of
v0
through
v3
to a reserved buffer of 4 bytes. Octo lets us define a label with
:
which we can then refer to when setting
i
:
i := buffer # initialize the index register
save v3 # write out the bottom four registers# ...
: buffer 0 0 0 0
A
save vf
or
load vf
will stash or restore 16 registers in two instructions; very handy! This is especially useful at program startup. Most CHIP-8 interpreters will zero the v-registers before your program executes, but this behavior is not universal.
Initializing four registers
directly
with
vx := NN
takes four instructions, and eight bytes:
v0 := 11
v1 := 22
v2 := 33
v3 := 44
Initializing four registers with a
load
also takes eight bytes, but only two instructions. Any more than four registers will save both program space and execution time:
i := startup
load v3# ...
: startup 11 22 33 44
Don't assume the v-registers contain 0 when your program starts: initialize them explicitly.
Note that
any
load
or
save
involves the
v0
register. You’ll need this register available for temporary use throughout your program. Similarly, the
vf
register is mangled or altered by most of the arithmetic instructions and the
sprite
instruction- you can’t use it for long-term storage even if you wanted to. Since
load
s and
save
s always work on the low registers, you should organize your register usage from least to most persistent:
v0
,
v1
,
v2
… should contain information that is relevant only to a local subroutine, while
ve
,
vd
,
vc
… can contain information that tends to remain useful for the whole lifetime of a program. Be sure to use Octo’s
:alias
directive to give registers meaningful names, and thus give yourself flexibility for rearranging them while developing longer programs.
In practice, I recommend trying to keep
at least
v0
and
v1
“free” for use in leaf subroutines along with
vf
; being able to write a pair of bytes in one go is necessary for a variety of self-modifying code tricks we’ll touch on later.
Slinging up to 16 bytes around is handy for doing fast array copies or fills. Unfortunately, if we want our programs to be portable we need to remain aware of another common bug in interpreters: the original CHIP-8 interpreter would automatically increment
i
after a
load
or
save
by the number of bytes read or written. The “SCHIP” interpreter for the HP-48 calculator- and many modern imitators- leaves
i
intact. The following fragment will zero 32 bytes starting from
temp
on vintage CHIP-8:
i := blank
load v7
save v7
save v7
save v7
save v7# ...
: blank 0 0 0 0 0 0 0 0
: temp
But it won’t zero
any
bytes of the
temp
buffer on an interpreter with this misbehavior, because
i
never changes! A whole class of fun tricks is possible with
either
the original or the SCHIP behavior, but if we don’t know what we’ll get, we have to be conservative:
Never re-use the position of
i
after a
load
or
save
operation.
Supposing you have an array:
: data 1 1 2 3 5 8 11
Reading an element from the array by an index in
v1
is straightforward. This approach can address up to 256 bytes:
i := data # set i to the base address
i += v1 # add an offset (in this case, the value in v1)
load v0 # load data[v1] into v0
If entries in the array are two bytes wide, we could add our offset to
i
twice; this approach can address up to 512 bytes:
i := data # set i to the base address
i += v1 # i is data+v1
i += v1 # i is data+2*v1
load v1 # v0 is data[2*v1], v1 is data[(2*v1)+1]
If we need to perform indirect addressing- looking up a pointer in a table and then indexing into it- we’ll need to write self-modifying code. Given a table of
i := NNN
instructions which represent our “pointers”, we can load an instruction into
v0
/
v1
and then write it back to a different location, replacing a no-op instruction:
i := pointers # (assume the table index is in v0)
i += v0 # table entries are 2 bytes,
i += v0 # so add the index twice
load v1 # load the pointer into v0-v1
i := get-pointer # choose the destination
save v1 # overwrite the old instruction
: get-pointer
v0 := v0 # no-op which becomes an i := NNN# ...
: pointers
i := 0xAAA
i := 0xBBB
i := 0xCCC
i := 0xDDD
i := 0xEEE
This technique is especially efficient if the instruction we’re overwriting is within a loop. The initial fetch-and-overwrite process takes 6 instructions, but on subsequent fetches it’s only necessary to execute the single
i := NNN
instruction.
If we happen to have a 12-bit address already in the
v0
and
v1
registers, we can skip a few steps and construct the
i := NNN
instruction on the fly:
vf := 0xA0 # constant
v0 |= vf # now v0-v1 contain our instruction
i := get-pointer # choose the destination
save v1 # overwrite the old instruction
: get-pointer 0 0
The
i := hex vx
instruction is a specialized built-in array lookup: it points the index register
i
at a 5-pixel-tall, 4-pixel wide sprite representing the lower hexadecimal digit of the contents of
vx
. The original CHIP-8 interpreter referenced a cleverly overlapped series of digits in the COSMAC VIP ROM, while modern interpreters tend to stash a font at
0x000
and set
i
to
vx * 5
. Beware: different interpreters use different fonts, and they don’t all perform careful bounds-checking.
Do not depend on the appearance of the built-in hex font, and do not execute
i := hex vx
with a value greater than 15 in
vx
.
The only other instruction which writes to memory is
bcd vx
. This is also a bit odd and specialized. If you point
i
at a three-byte buffer,
bcd vx
will decode the value in
vx
into digits- hundreds, then tens, then ones- in the cells of the buffer. Thankfully, there’s no indexing ambiguity for this instruction: it always leaves
i
unchanged. Many early CHIP-8 games used this instruction for implementing score displays:
va := 10 # horizontal position for the score counter
vb := 10 # vertical position for the score counteri := decode-buffer # point to a temporary buffer
bcd v0 # decode a numeric value in v0
load v2 # v0 is hundreds (0-2), v1 is tens (0-9), v2 is ones (0-9)
i := hex v0 # sprite for hundreds
sprite va vb 5 # draw a 5-pixel tall sprite
va += 5 # advance 5 pixels horizontally, leaving a space between digits
i := hex v1 # sprite for tens
sprite va vb 5 # draw a 5-pixel tall sprite
va += 5 # advance 5 pixels horizontally, leaving a space between digits
i := hex v2 # sprite for ones
sprite va vb 5 # draw a 5-pixel tall sprite
: decode-buffer 0 0 0
Octo’s
:macro
facilities can help tidy up repetitive code like displaying each of these digits:
:macro digit REG {
i := hex REG
sprite va vb 5
}
digit v0 # hundreds
va += 5
digit v1 # tens
va += 5
digit v2 # ones
It is possible to leverage
bcd vx
as a way of dividing numbers by 10- or taking them modulo 10- but having to go through memory and modify
i
makes this awkward. When you need to display a number greater than 255, it is often best to store two digits in a byte (0–99) and only use the two low bytes of a
bcd vx
result. Alternatively, store each digit of the number in its own byte to begin with and thus entirely remove the need for the
bcd vx
instruction!
Subroutines
|
Machine Code
|
Octo Syntax
|
00EE
|
;
or
return
|
2NNN
|
NNN
or
:call NNN
|
Like Forth, Octo gives the lightest possible syntax to subroutines and subroutine calls. A colon (
:
) defines a label. Referencing a label name is a call to that label. A semicolon (
;
) returns from a subroutine
2
.
: washer wash spin rinse spin ;
It’s slightly more efficient to replace function calls which are immediately followed by a
return
with a
jump
instead:
: washer wash spin rinse jump spin
That’s Octo-style
Tail Call Elimination
!
The original CHIP-8 interpreter has a 12-level return stack, but most modern interpreters raise the limit to 16. Some crash if you over-nest, some nest indefinitely, and some quietly wrap and overwrite the oldest stack entries. In practice, well-formed programs will rarely even need 12 nested function calls, as recursion is impractical unless you furnish your own auxiliary parameter stack.
Don't write programs which make more than 12 nested subroutine calls.
One little trip-hazard to be aware of with Octo’s subroutine call syntax: Octo does not distinguish between
labels
and
constants
. If you define a constant and then use the name on its own, it will be assembled as a function call:
:const foo 0xAB
foo # 0x20 0xAB
If you want to assemble
the value of a constant
, use
:byte
:
:const foo 0xAB
:byte foo # 0xAB
If you want the name
foo
to assemble as a literal byte, you could also declare a
:macro
instead of a constant:
:macro foo { 0xAB }
foo # 0xAB
Control Flow
|
Machine Code
|
Octo Syntax
|
1NNN
|
jump NNN
|
3XNN
|
if vx != NN then
|
4XNN
|
if vx == NN then
|
5XY0
|
if vx != vy then
|
9XY0
|
if vx == vy then
|
BNNN
|
jump0 NNN
|
Conditional instructions in CHIP-8 are mostly conditional skips. Note that Octo’s syntax “inverts” the way conditions are shown relative to some other descriptions of these opcodes you may find online. The
3XNN
opcode skips the next instruction if
vx
is equal to the literal
NN
. Octo writes the same instruction as
performing
the next instruction if
vx
is not
equal to
NN
.
Octo also furnishes “pseudo-op” conditionals; you can use
<
,
>
,
<=
, or
>=
in conditional expressions like so:
if v0 > v1 then
And they will be expanded into logically equivalent constructions:
vf := v1
vf -= v0
if vf == 0x00 then
As we’ve noted previously, this type of construction can pose portability problems for buggy interpreters, because it uses arithmetic operators with
vf
as the destination register. It is also important to keep in mind that these comparisons are
unsigned
. While Octo will happily allow users to store immediate values like
-1
in a v-register as its two’s-complement representation (
0xFF
), two’s-complement is
merely a state of mind
.
0xFE
is greater than
0x03
, even if you intend for those values to mean -2 and 3, respectively. If you want to compare values that could be “negative”, consider storing them with an added bias. Use the pseudo-op conditionals with caution, if at all.
jump NNN
is straightforward enough. Octo encourages using structured programming, so we’ll generally write backward jumps as a
loop ... again
infinite loop. Inside such a loop you can have any number of
while
clauses which, if unsatisfied, branch forward to the end of the loop. You can also use an ordinary conditional to skip over the
again
at the end. Summarized,
|
Octo syntax
|
C syntax
|
loop ... again
|
do{...}while(1);
|
loop ... if a == 0 then again
|
do{...}while(a==0);
|
while a != 0
|
if(a==0)break;
|
If we want to repeat a loop body 4 times, we write something like:
v0 := 0
loop
body
v0 += 1
if v0 != 4 then
again
Which we could also write explicitly with a label:
v0 := 0
: A
body
v0 += 1
if v0 != 4 then jump A
Keep in mind that repeated logic doesn’t necessarily mean we need a loop. If the loop body is factored as a subroutine, you can call it four times in sequence and avoid any conditionals or the need for an index variable:
body
body
body
body
This still spends an instruction for each subroutine call and return. If performance is paramount, directly inlining the instructions in this subroutine four times will be faster, at the cost of more program space.
Octo can synthesize nestable
if ... begin ... (else ...) end
conditionals from conditional skips and forward branches:
if v0 == v1 begin
va := 12
vb := 34
else
va := 56
vb := 78
end
Desugaring as:
if v0 != v1 then
jump A
va := 12
vb := 34
jump B
: A
va := 56
vb := 78
: B
This is very useful for writing programs that are easy to understand and modify, but over-reliance on these constructs can be inefficient. Consider this alternative which saves two bytes:
va := 56
vb := 78
if v0 != v1 then va := 12
if v0 != v1 then vb := 34
Redundant conditionals can be cheap!
It’s worth mentioning that Octo will automatically insert a
jump
instruction at address
0x200
to the
main
label if
: main
isn’t at the beginning of your program. This makes it more convenient to structure your program in a top-to-bottom reading order, but might be a surprise if you’re desperate for those last two bytes!
Rounding out our control flow rogues' gallery, the
jump0
instruction performs a jump to address
NNN
+
v0
. The intended use is building jump-tables, dispatching to a variety of code fragments or subroutines based on an index:
v0 := va # take an index from va
v0 += v0 # entries are two bytes wide, so double the index
jump0 table
: table
jump func1
jump func2
jump func3
(Can you see a way to make this an instruction shorter if you used a non-portable
vx <<= vy
?)
There are some limitations to this approach. While we can repeatedly add v-registers to
i
to index large regions of memory,
jump0
takes its offset from the 8-bit
v0
, so it can only address a total range of 255 bytes past the fixed label. Those bytes must be jump targets- valid instructions, which are always two bytes wide- so a table can only have a maximum of 128 uniform-stride entries if they’re
jump
instructions, or 64 entries if they’re pairs of instructions like
i := NNN return
.
As an extra inconvenience, the SCHIP interpreter has a buggy implementation of
jump0
which bizarrely uses the 4 high bits of the destination address to select the offset register instead of always using
v0
, making it essentially unusable. The
jump0
instruction was very rare in historical ROMs, which might explain why it was not thoroughly tested in some early interpreters, and why some of the first CHIP-8 variants removed
jump0
to make room for different instructions with a 12-bit immediate operand.
Avoid the
jump0
instruction for maximum portability.
At the cost of a few extra instructions, we can use the same idiom we saw for pointer indirection to replace
jump0
:
i := table
i += va # take an index from va
i += va # entries are two bytes wide, so double the index
load v1 # read the jump into v0/v1
i := trampoline
save v1 # overwrite the placeholder instruction
: trampoline
v0 := v0 # no-op to overwrite
: table
jump func1
jump func2
jump func3
Much like pointer indirection, we could use this technique to rewrite a
jump
or subroutine call in a loop, amortizing the cost of the self-modification. In the right situation, this technique
can be
faster and much more flexible than a
jump0
.
|
Machine Code
|
Octo Syntax
|
EX9E
|
if vx -key then
|
EXA1
|
if vx key then
|
FX0A
|
vx := key
|
The CHIP-8 hexadecimal keypad has the following 4x4 layout:
1 2 3 C
4 5 6 D
7 8 9 E
A 0 B F
There are two ways to get keyboard input: the
vx := key
instruction, which blocks until the user presses a key (and produces a somewhat jarring “beep” for each key read on the COSMAC VIP and some other interpreters), and a pair of conditional skip instructions which test whether a specific key is held down (
key
) or not held down (
-key
).
The former prevents any sort of animation or other game logic while it waits.
# wait for the user to press 'any key', blockingv0 := key
The latter are more complex to use, but essential for any kind of action game.
# wait for the user to pess 'any key', pollingloop
# ... insert some idle animation here
vf := 0
loop
if vf key then jump done
vf += 1
if vf != 16 then
again
again
: done
The conditional skip instructions need the index of the key they’re testing to already be in a v-register. In a tight game loop, if you can afford it, it’s best to keep those constants resident in some of the upper v-registers so you don’t have to constantly re-initialize them. Otherwise, if you’re testing multiple keys in sequence, you might be able to
load
a bank of key-constants in one go.
# a flickery but highly responsive moveable object: main
i := constants
load v6
i := hex v6
loop
clear
sprite v0 v1 5
if v2 key then v0 += -1
if v3 key then v0 += 1
if v4 key then v1 += -1
if v5 key then v1 += 1
again
: constants
30 # initial x position
15 # initial y position
:byte OCTO_KEY_A
:byte OCTO_KEY_D
:byte OCTO_KEY_W
:byte OCTO_KEY_S
0
Remember: you don’t have to use every key on the keypad! Simpler control schemes are easier for users to learn, and many CHIP-8 interpreters run on devices that have fewer than 16 gamepad keys.
Consider minimizing the number of distinct keys you use to control your programs.
If you’re polling keypad keys and waiting for a single keypress, it may be useful to frame it as waiting for the
rising edge
of a keypress:
vf := OCTO_KEY_E
loop if vf key then again # make sure the key isn't already pressed
loop if vf -key then again # wait for a press,
loop if vf key then again # wait for the release
You can add animation between the loops in this pattern to make your program feel more responsive to user input, but take care adding extra instructions to the polling loops: if they’re doing too much work, they can potentially miss very short key presses.
Random Numbers
|
Machine Code
|
Octo Syntax
|
CXNN
|
vx := random NN
|
The
vx := random NN
instruction loads a random byte bitwise
AND
-ed with the mask
NN
into a v-register. Writing the mask constant in binary can help clarify your intent. A few examples:
|
Constant
|
Possible values
|
0b00000001
|
0, 1
|
0b00000011
|
0, 1, 2, 3
|
0b00000110
|
0, 2, 4, 6
|
0b00001010
|
0, 2, 8, 10
|
0b00001000
|
0, 8
|
0b10000001
|
0, 1, 128, 129
|
0b00001111
|
0–15
|
0b01111111
|
0–127
|
Sometimes there’s confusion around this instruction with implementers assuming it will generate a number between 0 and
NN
. Thankfully, I have not seen this misbehavior in any mainstream CHIP-8 interpreters (yet). If you wanted to be extra-safe you
could
strictly use
0xFF
as the mask constant for this instruction- which would behave the same for either interpretation- and then perform an explicit bitwise
AND
with
vx &= vy
as needed.
Generating random numbers in some ranges with no relationship to powers of two can be tricky. The simplest approach might be to generate a number in a larger range, and retry several times if you get an invalid result:
: random-upto-9
v1 := 9
loop
v0 := random 0xF # 0-15 is the closest option to 0-9
if v0 > v1 then
again
;
For a non-uniform distribution, consider using the
vx := random NN
instruction to produce indices into a lookup table.
Output
|
Machine Code
|
Octo Syntax
|
00E0
|
clear
|
DXYN
|
sprite vx vy N
|
FX18
|
buzzer := vx
|
Generating sound in CHIP-8 is constrained to a simple on-or-off noisemaker. The
buzzer := vx
instruction tells the interpreter to make some kind of noise for
vx
60ths of a second. Re-issuing the instruction resets the countdown timer, so if
vx
is zero you can also use this instruction to immediately silence the buzzer. The noise made by the buzzer is implementation-dependent and varies wildly in volume and auditory discomfort. Octo defaults to using a “visual buzzer”, flashing the border color of the screen instead of making an audible sound.
The
sprite
instruction is the essential building-block of all CHIP-8 graphics. Using
vx
and
vy
to indicate a horizontal and vertical position on the screen in pixels, respectively, and
i
to indicate the image to draw, this instruction will draw an 8 pixel wide and
N
pixel tall image by bitwise
XOR
ing successive bytes of the data pointed to by
i
with the pixels of the screen. If any pixels on the screen are “flipped” by this process- that is, if our image’s “on” pixels overlapped with any pixels presently on the screen- we set
vf
to a nonzero value. Otherwise,
vf
will be set to zero. This can be useful for detecting whether an object we’ve drawn collides with anything else on the screen. Drawing the same image in the same place a second time will fully erase it.
There are several subtle variations on the behavior of this instruction across interpreters. Firstly, it is a common assumption that
vf
will be set to 1 when a “collision” occurs during sprite drawing. The SCHIP interpreter- among others- set
vf
to
the number of rows
containing a collision. This behavior is potentially an intriguing enhancement, but was not documented, and modern interpreters generally do not attempt to replicate it.
Don't compare
vf
to 1 after a
sprite
instruction; test whether it is zero or non-zero.
The SCHIP interpreter also offered an
intentional
alteration of the
sprite
instruction, using a size constant
N
of 0 to request drawing a 16x16 sprite, while the original CHIP-8 interpreter treated it as drawing an 8x0 pixel sprite (drawing nothing). The SCHIP extension is commonplace, but not strictly a CHIP-8 feature.
Don't draw a
sprite
with a height of zero in vanilla CHIP-8 programs.
There’s also some variation in how interpreters handle
sprite
s overlapping the edges of the screen: some will only draw
sprite
s within the 64x32 pixel window and clip anything beyond it, some will draw
sprite
s so long as their starting x and y coordinates modulo 64 and 32 (respectively) fall within the 64x32 pixel window, and some will draw every pixel of every
sprite
modulo the dimensions of the display, “wrapping” graphics around instead of clipping them. (Octo defaults to the latter.) It can be a bit limiting for some kinds of programs, but for maximum portability it is best to avoid drawing over the edges of the screen.
Draw
sprite
s with x coordinates between 0 and 56, and y coordinates between 0 and 32-N.
On the COSMAC VIP, the
sprite
instruction also waited for a vertical-sync on the display. In effect, this means every additional
sprite
instruction in a main loop directly reduced the framerate of a program! Modern interpreters tend to run programs much faster than the original interpreter, and should be designed to use the
delay
timer (as described in the following section) to help account for variation in execution speed. There’s no simple rule to follow here for compatibility, but you should be aware that
sprite
may
have a large intrinsic delay and each individual
sprite
drawing operation may be visible- if only for a fraction of a second- to the user.
This is part of why programs generally should not use the
clear
instruction to erase and then completely re-draw the display on every update: it leads to flickery, unpleasant-looking visuals. Static elements of a display- like the dotted “net” line in Pong- should be drawn once and left on the screen. Changing elements should be kept to a minimum, and should be erased and then re-drawn with
sprite
instructions. If the old and new version of a changing element fit within the same 8x15 (or smaller) rectangle, it is possible to design “pre-
XOR
ed” images which will erase the old image and draw the new image in a single step, avoiding any chance of a user seeing the “flicker” of the erased image before it is replaced. This is the key to buttery-smooth animation with CHIP-8! It is, however, incompatible with using the
sprite
collision flag in
vf
: we’ll
always
be toggling some pixels in an erase-and-redraw operation.
EZ-Pack
is a utility designed to work alongside Octo for cutting up images into
sprite
-sized chunks. The “XOR Frames” checkbox will instruct it to pre-
XOR
successive frames in an animation sequence together to support this kind of use-case, and the more specialized
EZ-Bake Animator
can help with drawing these kinds of sequences procedurally. It’s not unusual for authoring sophisticated CHIP-8 programs to require writing new supporting tools.
Timing
|
Machine Code
|
Octo Syntax
|
FX07
|
vx := delay
|
FX15
|
delay := vx
|
As we’ve mentioned previously, different CHIP-8 interpreters run at different speeds. Fortunately, we have a tool to help even things out: the delay timer. Write a value to the timer with
delay := vx
and it will asynchronously count down to zero at 60 ticks per second. The
vx := delay
instruction lets you poll the timer.
There are a number of ways this can be used for different types of programs, but there’s a common structure that applies to almost any action game: initialize the timer at the beginning of your main loop, execute the majority of your game’s per-frame logic, and then poll the timer until it returns 0:
loop
vf := 2
delay := vf # ... game logic goes here ...
loop
vf := delay
if vf != 0 then
again
again
If your program executes too slowly, the overhead of setting and checking the delay timer will only make the situation worse by a few instructions. If your program executes faster than you expect, the
delay
loop will burn off the extra cycles.
3
Initializing the timer to 1 will “expect” the main game loop to complete in less than 1/60th of a second, for a 60 frames per second target- maybe a bit ambitious for interpreters on older or more constrained hardware. Initializing it to 2 will “expect” it to take 2/60ths of a second, for 30 frames per second, and so on.
Very few historical CHIP-8 ROMs use the delay timer in this fashion, instead relying on the inherent slowness of the
sprite
instruction to regulate their framerate. As a result, they need special coddling by interpreters to stop them from running too fast. Use the
delay
timer and
your
programs will work consistently on the interpreters of yesterday and tomorrow.
Where To Go From Here
That’s CHIP-8. 34 instructions which can easily fit in your head. Some odd and interesting features like the
load
and
save
instructions that can manipulate slabs of registers at once, or the
sprite
drawing instruction that encourages thinking of all your graphics and collision tests as deltas against an opaque framebuffer. You’ve learned everything you need to know about the sharp corners the platform has acquired over the decades- instructions and practices to avoid, and ways around them. You’re ready.
CHIP-8 offers many of the puzzles and challenges available in any form of assembly language, but with a much smaller surface area and a much broader range of targets than even
8088 boot sectors
. It’s an inviting gateway for learning about bits, bytes, shifts, and cycles, and developing a feel for “bare metal” programming. Every programming enthusiast, computing student, and budding compiler developer should give it a spin. You might be surprised just how much you can do with 64x32 pixels and 3232 bytes.
What are you waiting for? Why not
go write some software
? If it runs on CHIP-8, it’s immortal.
Further Reading
-
The
Octo Reference Manual
-
An Octo Implementation In C
-
The
CHIP-8 Archive
; modern games written for CHIP-8
-
Tobias V. Langhoff’s
Guide to making a CHIP-8 Emulator
back