Case study - Writing fast Commodore 64 Basic code

Table of contents

I wrote a game in C64 Basic, which was released in 2018. The game is called Jake the Snake, and the version I will refer to in this article is 5.0. See download link below. In the zip-archive you download, you will find code notes to help you navigate the code. I will go through what I have done in the different sections of the program to optimize for speed.

Note: Download the game from this page.

Note: The source code of the game can also be found in HTML form here.

Note: All parts of the source code will not be explained here, only parts which hold a performance optimization lesson. To understand what the different parts of the source code do, you should read the code notes included in the game distribution.

Program start (line 0)

The most important part of the program speed-wise, is the main game loop at line 1-15. Every line that comes before this will make each GOTO statement within the main game loop slower, since these GOTO statements search from the start of the program. For this reason, I just write a single line of code here to jump to line 470, where calls are made to initialize the program, show the menu etc. Since it's only the number of lines that matter here, not the length of the lines, I also take the opportunity of adding a message about the license.

Main game loop (line 1-20)

This is the code that will determine the maximum speed of the game, so of course speed is of essence here. For starters, I have placed the code on the lowest line numbers possible. There has to be a line before this to jump to the code to initialize the game and all that, so the main game loop starts at line 1 with line number increments of 1.

Line 1: This is where player input is read. I added "IFPEEK(198)THEN" so that I don't have to execute a GET and two string comparisons when the player hasn't hit a key anyway (Address 198 holds the number of keys currently held in the keyboard buffer). I also use a lookup table in the form of an array variable called N to find the new direction of the snake when the player has hit a key. The variable D (direction) should always hold one of the values -40 (up), 40 (down), -1 (left) or 1 (right). Since it's not possible to use negative indexes in an array variable, I add 40 to the formula giving the index. To make it faster, I have put the constant 40 into the variable Z. A comparison operator (I$=".") returns -1 if the comparison holds true, otherwise 0. The lookup table is initialized on line 565-570.

Line 2: The position of the snake's head is updated. P holds the memory location of the snake's head on the screen (1024 for the top left corner), so I don't have to add 1024 when poking the head onto the screen. C holds the screen column (0-39) where the snake's head is currently located. I then go on to check if the head has now passed the top, left or right borders. Since I don't use IF to decide if C should be updated, I can squeeze all of this into one line. I tried several ways of testing if the snake moves sideways, and found ABS(D)=1 to be the fastest. I could check for the bottom border here as well, but I don't have to, since it will be detected as a collision killing the snake in line 6 anyway.

Line 3-6: I now look at the character in the position where the snake head should now move to. It may seem like a good idea to store the value of PEEK(P) into a variable rather than performing the same PEEK operation three times. However, the most important case is checked first - most of the time only line 3 (check if it is an empty space) will be executed, and that line is faster with just a PEEK rather than storing the value in a variable and then checking that variable. Originally, I didn't have line 3. Instead I wrote line 6 as "IFPEEK(P)<>QTHEN770". This turned out to be slower, even if I stored the peeked value in a variable. The only other code which may be called from this loop is if you eat an apple (GOSUB200 in line 4). If you complete the level you leave the loop on line 5. If you die, you leave it on line 6.

Line 7: V=1-V will make V alternate between the values 0 and 1, without any IF:s. This is used to start a sound effect every other game turn.

Line 8: Set the screen colour below the snake head to white. Y is a constant holding the difference between the starting address of color RAM and screen RAM. In this way, this formula can be kept as simple as possible. Next, I check if the snake's current length is > 0 ("IFLTHEN" will check if L <> 0, it is faster than "IFL>.THEN" and I know L will never be negative anyway). If the length is > 0, the last position where the snake's head was should be replaced by one of six different characters, showing a straight or bending snake. The logic of which character to pick depends on the current direction and the last direction of the snake. This logic has been put into a lookup table (SY). To avoid negative values, the constant 120 is added. 2*XD is written as XD+XD for speed. T% is an array of integers which holds all the screen positions used by the snake. H holds the index into T% where the head of the snake is. Since we haven't updated H yet, it still refers to where the snake's head was last turn. An array of floats (such as T) would be faster than T%, but that would use up too much memory.

Line 9: Now we update the pointer into the T% array. H=(H+1)ANDN would have the same effect, but is in fact slower. This is because parentheses are slow and the AND operation is slow, while IF is pretty fast.

Line 10: POKE the new snake's head. Add the screen position to the T% array. If the snake is currenly shorter than it should be, increase the length and skip the line where the last part of the snake is erased.

Line 11: This erases the tail of the snake. What makes it complicated is that when you subtract the snake length (L) from the current index (H), you may end up with a negative number, which then indicates that we need to look that many positions from the end of the array instead. This could be expressed using an IF statement, but adding the array length (M) and performing AND with the array length minus one (N) is faster. M has to be a power of 2 for this to work.

Line 12: Turn off the sound effect, if it's playing.

Line 13: If TI (the number of jiffies since TI$ was set to "000000" or the computer was started) has now reached the next value in the array SL, change the color of a character on screen, to show that there is less time bonus to be obtained. The values where this should happen were originally calculated in the game loop, but since the calculations need to use division and some other slow operations, the values are calculated and put into an array before the game starts.

Line 14: If the game is now at its top speed, go back to line 1. This line makes the top speed of the game faster. The game will run slightly slower at all other speeds, but that's okay.

Line 15: Perform a delay loop and go back to line 1.

Line 20: Startup code for main game loop. This code was initially placed before the main game loop instead, but since it's only needed on the very first iteration, it's better to place it after the main game loop. Remember: Every line that comes before the main game loop slows down all jumps within the main game loop.

Eat an apple (line 200-265)

Line 200-202: Increase the target length of the snake (L holds the current length, while TL holds the length that it should grow to. GF holds the growth factor). Decrease the number of apples left to eat (E). Increase the score (PS). Increase game speed by decreasing the delay used in the main game loop (GS). Actually, this value is then adjusted to the speed setting of the game, and the actual delay to use is stored in GE.

Line 205: If there are no more apples to place (F), but the number of apples to eat (E) is not zero, skip the section on placing a new object.

Line 210-225: Place a new apple (S=90) or an exit symbol (S=0) in a random, empty location. In line 210, it would be faster to use a variable for 920. This code just hasn't been optimized as heavily as the main game loop. In line 220, note that it's typically faster to use a value in a variable which we know to be zero than to write "." or "0". That's why I write S=E. In line 225, we use the trick of keeping the difference between the start of colour memory and screen memory in a variable (Y) to keep calculations as simple as possible.

Line 227-260: Decrease the number of apples left to get a bonus life (BA). Play a sound effect (different waveform if the player just earned a bonus life). Also store the right way to turn off the sound effect is XS. Since we don't have interrupts in Basic, we start a sound effect, do some other stuff we need to do anyway, and then turn off the sound effect again. In this way we get a delay without using a delay loop (which would make the game pause for a litle while).

Line 261-262: Print the number of apples left to get a bonus life. Since we will access the value LEN(S$) several times, store it in a variable instead of recalculating it each time.

Line 263: Update the game speed display. To save time, only update the display if it has changed since last time.

Line 265: Jump to the code to update the bottom line of the screen (score, apples left to eat etc). This is in fact a subroutine, but since we're already in a subroutine and mean to return after this, we can instead GOTO400 to jump to the other subroutine. This is faster than GOSUB400:RETURN.

Print status line (line 400-412)

You can print a regular status line by doing GOSUB400, or you can set S$ to some custom text message and do GOSUB410.

Line 411: Poking the whole statusline in place would be really slow. Instead, we use a trick where we set 214 to the line number minus one and 211 to the column where we want the cursor. We then print C1$ (which always holds CHR$(13)) to position the cursor in the first column of the desired line. (Note: I actually misunderstood the way this works a bit. The right way to do it is (1) POKE line - 1 into address 214, (2) print a newline character, (3) POKE column into address 211 and (4) print the string you want to print. Performing POKE to 211 before printing newline, as I did above, is of course utterly pointless. Since I wanted the cursor in column 1, it worked fine anyway.)

Intialize program (line 540-590)

First set the initial values of all regular variables used in the program (including constants), in order of importance. Variables referred to often in a typical iteration of the main game loop come first etc. But I try to initalize every single variable used, since adding variables later will mean moving all arrays, which takes time.

Then initalize all array variables used.

Finally, setup the values that need to be in lookup tables.

End by calling the subroutine for reading the highscore file from disk at line 4000. Instead of GOSUB4000:RETURN, I opt for the faster GOTO4000.

Setup a new level (line 600-618)

For the first several versions of the program, the levels were stored in DATA statements, specifying a number of horisontal and vertical lines to be drawn on the screen using reverse spaces. This was compact and worked well, but was pretty slow. So, I changed it into a subroutine for each level, which prints the level using a minimal number of PRINT statements, including cursor movements. This adds quite a bit to the program size, but makes level initalization much faster.

Menu screen (line 5000-5985)

When the player changes the difficulty setting or speed setting, the cursor is positioned using a string containing all the cursor movmements required to go there (line 5735 and 5760). This is quite fast. Also, the strings needed to draw the different speed settings are already in an array (they are read from DATA statements at 9000-9009).

The six different highscore tables that can be shown are pre-generated in 5800-5850. The resulting strings are put into an array. Printing these strings from the array is a lot faster than it would be to generate them when they are needed.

Printing the demo level is done with print statements at 5900-5970, just like the actual levels for the game are printed. This is fast.

Level printing (line 30000-33624)

See notes under "Setup a new level (line 600-618)" above.

Fredrik Ramsberg, 2019.