Restructuring a 1K ZX-81 BASIC Game

Introduction

Tony Lyon, a fellow heavily into retro video gaming, tweeted the above image of a listing of a ZX-81 BASIC “Space Invader” (note the singular!) game that fits in 1K. Erico Patricio Monteiro posted it to the Facebook CoCo group, and CoCo users have been looking at it since. I succumbed to temptation, and here we’ll take a shot at restructuring it, ultimately rewriting it in BASIC09.

Step One: Get the Text

First, let’s get this in text format. This photo is of a listing from the days of low-res dot matrix printers, so I may make a mistake. I’ll use lower case, and will use strings of question marks as a first cut for the laser cannon, the ship (again, note the singular), and the like. We can fill in something plausible later. For now I will put a tab between line numbers and lines; this is for human understanding, as far as that is possible in street BASIC[1].

 10        let a=pi/pi
20        
let b=pi-pi
30        
let c=15
40        
let d=25
50        
let s=b
55        
let h=21
60        
let e$= "???"
70        
let g=a
80        
cls
90        
let x=int(rnd*26)
100        
let y=int(rnd*h)
110        
print at h,c;"???"
115        
if y>h then let y=h
120        
print at y,x;e$
124        
if y=h and x=c or y=h and x=c-a or y=h and x=c+a then goto 600
126        
if y=h and x=c-2 or x=c+2 then goto 600
128        
if y=h and x<>c then goto 80
130        
if inkey$="A" then let c=c-a
135        
if c<b then let c=b
140        
if inkey$="P" then let c=c+a
145        
if c>26 then let c=26
150        
if inkey$="1" then goto 400
170        
let y=y+g
180        
cls
190        
goto 110
400        
if c<>x then goto 170
410        
print at y,x-a;"???"
420        pause 4
430        
let s=s+d
440        
if s=1000 then goto 500
450        
goto 80
500        
let e$="???"
510        
let d=d+d
520        
goto 80
600        
cls
610        
print "score=";s

Step Two: Line Number Exorcism

Street BASIC, like some other early interactive languages of the time (e.g. JOSS and FOCAL), requires a line number on each and every line.  This is evil.

Why? It’s dense camouflage hiding the control flow. Here’s a line of BASIC, with a number. Quick! How many other places in the program end up here? Until you read the entire program, you don’t know. Any other line might branch to it.

So, here’s the program with only the line numbers that are actually used. We’ll insert a blank line before each numbered line.

        let a=pi/pi
        
let b=pi-pi
        
let c=15
        
let d=25
        
let s=b
        
let h=21
        
let e$= "???"
        
let g=a

80        
cls
        
let x=int(rnd*26)
        
let y=int(rnd*h)

110        
print at h,c;"???"
        
if y>h then let y=h
        
print at y,x;e$
        
if y=h and x=c or y=h and x=c-a or y=h and x=c+a then goto 600
        
if y=h and x=c-2 or x=c+2 then goto 600
        
if y=h and x<>c then goto 80
        
if inkey$="A" then let c=c-a
        
if c<b then let c=b
        
if inkey$="P" then let c=c+a
        
if c>26 then let c=26
        
if inkey$="1" then goto 400

170        
let y=y+g
        
cls
        
goto 110

400        
if c<>x then goto 170
        
print at y,x-a;"???"
        pause 4
        
let s=s+d
        
if s=1000 then goto 500
        
goto 80

500        
let e$="???"
        
let d=d+d
        
goto 80

600        
cls
        
print "score=";s

Hey, the last couple of lines before line 500 are

        if s=1000 then goto 500
        
goto 80

so they could just as well be the single line

                If s<>1000 then goto 80

eliminating any need for the line number 500. Even better, then you could do all that as

                if s<>1000 then goto 80
                
let e$="???"
                
let d=d+d
                
goto 80

Step Three: INKEY$

We see the common idiom of using inkey$ to get a character indicating the player’s intention, but why three times? Surely

                let k$=inkey$
                
if k$="A" then...
etc. would be smaller and faster, and would prevent your keystroke from being read, compared with something other than what you typed, and discarded for the next inkey$ call.

Step Four: Demunging

There are many articles telling you how to mangle street BASIC code into unintelligibility to somewhat compensate for the interpreter’s extreme inefficiency. The sort that stands out here is saving constants in variables to avoid the overhead of the interpreter converting it from human-readable to internal format over and over and over… For even more fun, the code takes advantage of this particular BASIC interpreter setting pi to an approximation of pi, so the first two lines set a to 1 and b to 0. You can’t do that in Color BASIC, where uninitialized variables default to 0.

We will ultimately move to BASIC09, which is definitely not “street BASIC”. In particular, code is converted to an internal form as soon as possible. That internal form

  • keeps numerical values in binary representation, so no endless conversion at runtime
  • keeps expressions in reverse Polish notation, so no endless parsing at runtime
  • accesses variables by offsets, so no endless linear symbol table lookups at runtime

so to make the code more understandable for us humans rather than better for a primitive interpreter, we will get rid of these variables that just hold constants, except for h. (We have plans for h.) When the smoke clears…

         let c=15
        
let d=25
        
let s=0
        
let h=21
        
let e$= "???"

80        
cls
        
let x=int(rnd*26)
        
let y=int(rnd*h)

110        
print at h,c;"???"
        
if y>h then let y=h
        
print at y,x;e$
        
if y=h and x=c or y=h and x=c-1 or y=h and x=c+1 then goto 600
        
if y=h and x=c-2 or x=c+2 then goto 600
        
if y=h and x<>c then goto 80
        
let k$=inkey$
        
if k$="A" then let c=c-1
        
if c<0 then let c=0
        
if k$="P" then let c=c+1
        
if c>26 then let c=26
        
if k$="1" then goto 400

170        
let y=y+1
        
cls
        
goto 110

400        
if c<>x then goto 170
        
print at y,x-1;"???"
        pause 4
        
let s=s+d
        
if s=1000 then goto 500
        
goto 80

500        
let e$="???"
        
let d=d+d
        
goto 80

600        
cls
        
print "score=";s

Step Four: Looking at Y

Is Y=H? ...How About Now?

There’s a lot of repeated evaluation in those three lines starting with “if y=h”:

        if y=h and x=c or y=h and x=c-1 or y=h and x=c+1 then goto 600
        
if y=h and x=c-2 or x=c+2 then goto 600
        
if y=h and x<>c then goto 80

Thanks to the line number exorcism, we know nobody will change any of these variables and jump into the middle, so we can just as well rewrite it as

        if y<>h then goto 130
        
if abs(x-c)<3 then goto 600
        
goto 80

and then put line number 130 back on the start of the inkey$ checking.

Where Y Changes

A couple of lines after line 80, y is set to a random value that surely won’t exceed h, so why is there that test that bounds y to be no more than h? It’s only modified on line 170. Since that’s the only place it can ever exceed h, let’s move that check after line 170.

The Code So Far

Let’s see what we’ve come to.

         let c=15
        
let d=25
        
let s=0
        
let h=21
        
let e$= "???"

80        
cls
        
let x=int(rnd*26)
        
let y=int(rnd*h)

110        
print at h,c;"???"
        
print at y,x;e$
        
if y<>h then goto 130
        
if abs(x-c)<3 then goto 600
        
goto 80
130        k$=
inkey$
        
if k$="A" then let c=c-1
        
if c<0 then let c=0
        
if k$="P" then let c=c+1
        
if c>26 then let c=26
        
if k$="1" then goto 400

170        
let y=y+1
        
if y>h then let y=h
        
cls
        
goto 110

400        
if c<>x then goto 170
        
print at y,x-1;"???"
        pause 4
        
let s=s+d
        
if s=1000 then goto 500
        
goto 80

500        
let e$="???"
        
let d=d+d
        
goto 80

600        
cls
        
print "score=";s

Colon-ization

Apparently ZX-81 BASIC doesn’t let you run multiple statements together on a line. Color BASIC does, and it even has an ELSE. Let’s sort of switch to Color BASIC (still leaving off unused line numbers for now, and ignoring the PAUSE, which waits a number of “ticks”) and see if things can be made clearer. (And we’ll READ those variables rather than assign them one per line.)

        read c,d,h,s,e$
        
data 15,25,21,0,"???"

80        
cls
        x=
rnd(26)-1
        y=
rnd(h)-1

110        
print@ h,c;"???"
        
print@ y,x;e$
        
if y=h then if abs(x-c)<3 then 600 else 80
        k$=
inkey$
        
if k$="1" then 400
        
if k$="A" and c>0 then c=c-1
        
if k$="P" and c<26 then c=c+1

170        
if y<h then y=y+1
        
cls
        
goto 110

400        
if c<>x then 170
        
print@ y,x-a;"???"
        pause 4
        s=s+d
        
if s=1000 then e$="???":d=d+d
        
goto 80

600        
cls
        
print "score=";s

What Do All Those One-Letter Variables Mean?

That’s a good question for any street BASIC program. The final PRINT statement gives away[2] that s is the score. x and y suggest coordinates in a two-dimensional space, but we need more evidence.

Next clue: the player can move the defensive weapon. The only variable changing depending on inkey$ values is c, and considering the print at statements starting at line 110, I’m guessing the invader is at (y,x) and the defender at (h,c), or perhaps it’s (x, y) and (c, h);  maybe ZX-81 flips the coordinates.  h is set once and doesn’t change, consistent with Space Invaders, where you can only move your defensive weapon left and right. (Further evidence: code comparing  y and h and x and c. It’s a collision check. )

Moving to BASIC09 (Finally!)

Enough one-letter variable names. Time for BASIC09, because it has capabilities this program desperately needs:

  • The mostly optional line numbers that we’ve been using post-exorcism. Ultimately we hope to remove them all using BASIC09’s superior flow control statements; we’ll see.
  • The ability to use longer variable names at minimal cost. The internal form of the code doesn’t use the names at all, so a BASIC09 procedure has exactly one copy of the name of any variable or parameter in it.
  • BASIC09’s TYPE facility, which gives you what in the type biz is called a “product type”. In Algol 68 or C they’re structs;  in Pascal, record types. TYPE lets you gather up the information about something in a single place rather than scattering it across a bunch of seemingly unrelated variables.
  • procedures with local variables and parameters.

Let’s start with TYPEs. We are dealing with three kinds of things here:

  • The playing field, a rectangular region where things appear and happen.
  • The coordinates that specify a location.
  • An object that is on the playing field.

which suggest the following types:

TYPE POINT=x,y:INTEGER
TYPE REGION=max:
POINT
TYPE OBJECT=
loc:POINT; look:STRING[5]; dead:BOOLEAN

We’re wimping out a bit on REGION by assuming the minimum coordinates are zero;  we really should have a min in it as well. One could also argue about dead. Does the invader really die when it goes low enough, and do projectiles or beams die?

Curiously, the original has some constants actually appearing in code rather than being assigned to variables:

  • 26, which we place in playingField.max.x
  • 21, which we place in playingField.max.y

I guess the overhead of converting them wasn’t considered worth avoiding. Good practice these days considers such constants in code “magic numbers” and recommends giving them names to indicate their meaning. Writing street BASIC? Good luck finding a meaningful one- or two-letter variable name for them. In BASIC09? No problem.

So here’s a map from some of the random single-letter variables to what you’ll see below:

  • x in the original program becomes invader.loc.x here
  • y in the original program… you guessed it, invader.loc.y
  • c is, of course, defender.loc.x
  • h is defender.loc.y

TYPEs let you name things consistently, in a way that shows their purpose and what they belong to. In street BASIC, you get to infer that kind of information yourself, unless the author has added comments (but, but… with comments you couldn’t fit it in 1K!) or a separate explanatory text that you hope is kept up to date with the code.

There’s no variable in the original program that corresponds to the dead field in an OBJECT.  So how is that information represented in the street BASIC version? It’s represented by where you are in the code. You test the expression that corresponds to it and conditionally branch. In general, the current line number can thus represent an arbitrary amount of implicit state. Good luck keeping it all straight.

This is just refighting the structured programming wars, which structure long ago won. In 1966, Böhm and Jacopini proved that any program can be cast in a structured form, if you’re willing to put up with some additional variables[3]. The dead fields are just that. collisionCheck sets them to TRUE if needed, and the main loop checks them. Back in the day, GOTO fans[4] claimed that setting and testing those added variables was just too inefficient… but I think it’s worth it for clarity. How long does the loop run? Until the defender’s dead.

So here’s an initial cut of… OK, it’s not the initial cut. You’ll see the offsets that appear in BASIC09 listings, so this is code that basic09 actually accepts and can run. Apologies for the funky colors; Code Blocks does nicely for setting source code, but… (I told it it was BASIC, but it got confused with the strings somehow.)

PROCEDURE spaceInvader
0000      TYPE
POINT=x,y:INTEGER
000F      TYPE REGION=max:
POINT
001C      TYPE OBJECT=
loc:POINT; look:STRING[5]; dead:BOOLEAN
003A      
003B      
DIM invader,defender,projectile:OBJECT
004C      
DIM playingField:REGION
0055      
DIM score,points:INTEGER; key:STRING[1]
006B      
006C      
READ playingField.max.x,playingField.max.y
0083      
DATA 26,21
008D      
READ defender.loc.x,defender.loc.y
00A4      
DATA 15,21
00AE      
READ defender.look,invader.look,projectile.look
00C7      
DATA "/^\","(0)"," * "
00DD      READ defender.dead,invader.dead,score,points
00F6      DATA FALSE,TRUE,0,25
0104      
0105      LOOP
0107        IF invader.dead THEN
0113          invader.loc.x:=INT(RND(playingField.max.x))
012C          invader.loc.y:=INT(RND(playingField.max.y))
0145          invader.dead:=FALSE
014F        ENDIF
0151        RUN gfx2("
clear")
015E        RUN display(defender)
0168        RUN display(invader)
0172        RUN collisionCheck(invader,defender)
0181      EXITIF defender.dead THEN ENDEXIT
0190        RUN inkey(key)
019A        IF key="" THEN
01A6          GOSUB 100
01AA        ELSE
01AE          ON SUBSTR(key,"
ap1")+1 GOSUB 100,200,300,400
01CE        ENDIF
01D0      ENDLOOP
01D4      
01D5      RUN gfx2("
clear")
01E2      PRINT "
score = "; score
01F2      END
01F4      
01F5 100  IF invader.loc.y<defender.loc.y THEN
0211        invader.loc.y:=invader.loc.y+1
0229      ENDIF
022B      RETURN
022D      
022E 200  IF defender.loc.x>0 THEN
0243        defender.loc.x:=defender.loc.x-1
025B      ENDIF
025D      RETURN
025F      
0260 300  IF defender.loc.x<playingField.max.x THEN
027C        defender.loc.x:=defender.loc.x+1
0294      ENDIF
0296      RETURN
0298      
0299 400  IF defender.loc.x<>invader.loc.x THEN
02B5        GOSUB 100
02B9      ELSE
02BD        projectile.loc.x:=invader.loc.x-1
02D5        projectile.loc.y:=invader.loc.y
02EA        RUN display(projectile)
02F4        invader.dead:=TRUE
02FE        RUN nap(4)
0306        score:=score+points
0312        IF score<1000 THEN
031F          points:=points+points
032B        ELSE
032F          invader.look:="
\X/"
033D        ENDIF
033F      ENDIF
0341      RETURN
PROCEDURE collisionCheck
0000      TYPE POINT=x,y:INTEGER
000F      TYPE OBJECT=loc:POINT; look:STRING[5]; dead:BOOLEAN
002D      
002E      PARAM invader,defender:OBJECT
003B      
003C      IF invader.loc.y=defender.loc.y THEN
0055        IF ABS(invader.loc.x-defender.loc.x)<3 THEN
0072          defender.dead:=TRUE
007C        ELSE
0080          invader.dead:=TRUE
008A        ENDIF
008C      ENDIF
PROCEDURE display
0000      TYPE POINT=x,y:INTEGER
000F      TYPE OBJECT=loc:POINT; look:STRING[5]; dead:BOOLEAN
002D      
002E      PARAM obj:OBJECT
0037      
0038      RUN gfx2("
curxy",obj.loc.x,obj.loc.y)
005B      PRINT obj.look;
PROCEDURE nap
0000      TYPE REGISTERS=cc,a,b,dp:BYTE; x,y,u:INTEGER
0025      
0026      PARAM ticks:INTEGER
002D      DIM regs:REGISTERS
0036      DIM code:BYTE
003D      
003E      regs.x:=ticks
004A      code:=$0A
0052      RUN syscall(code,regs)

So… we’ve gotten rid of all the line numbers save those on the subroutines. Alas, BASIC09 doesn’t have the TrueBASIC SELECT…CASE that would let us avoid them.

Why the explicit check for an empty string coming back from inkey? Think about it. I claim that SUBSTR(“”, s) will return 1 no matter what string s is. Why? Every character in the empty string can be found, in order, at the beginning of s, because there are no characters in the empty string. Therefore, it gives us a false positive on  the ON…GOSUB.

What have we forgotten?

  • Randomizing the random number generator seed.
  • Remembering the echo setting for standard input, turning it off, and putting it back to its remembered state at the end.
  • Slowing it down enough that I might actually get a positive score.

Those, and picking a decent set of characters to press for left, right and fire, we’ll leave as an exercise to the reader.

If you have the urge to extend it, it’s easy to write something like

DIM defender,invader(10):OBJECT

along with adding a variable to track the current number of invaders, code to increase the number of invaders, and code to iterate over them if you want to make life tougher for the player. (Try that in street BASIC.)


[1] “Street BASIC” is how Kemeny and Kurtz, the inventors of BASIC, referred to the primitive BASIC interpreters prevalent on microcomputers of the 1980s, mostly variants of Microsoft’s BASIC interpreter originating with the version for the Altair. I would’ve sworn I saw “gutter BASIC”, but I can’t confirm it, so I’ll stick with “street BASIC”.

[2] “Gives away” are les mots justes. For programs of any significance or size, street BASIC actively conceals the meaning of the code, due to constraints on variable names, lack of reasonable control structures, and the active obfuscation programmers are forced into to make up for the interpreter’s inefficiency.

[3] Knuth points out in "Structured programming with goto statements" that this doesn’t mean much. Any program can be turned into a state machine and written as a loop around a switch statement, encoding everything just like the street BASIC space invader encodes whether the defender is dead. SINO (structured in name only).

[4] Yes, there were some back then.