Writing a game in Forth alone is challenging. White Lightning addresses this with a sub-language called IDEAL - Interrupt Driven Extensible Animation Language. This provides the graphics extensions supporting sprites and other effects. There are 200 or so Forth words in IDEAL, roughly grouped into:
Sprite management
Copying sprites to and from the screen or other sprites
Scrolling
I will illustrate these in the context of writing the core of a version of Konami's Frogger. It's not quite the "Hello World" of video games - that would be Snake ( Typing "bing snake" into bing supplies a minimalist instance). Nevertheless Frogger is both simple enough in terms of game play and demanding enough in terms of graphics to show the sort of things you could do with White Lightning. It is also sufficiently contemporary with White Lightning ( the arcade game appeared in 1981 ) to be the kind of game Oasis should have reasonably expected people to make. For my purposes, a minimal version of Frogger should include
Multiple lanes of traffic.
A river with multiple lanes of logs ( sorry, no turtles )
A animated frog ( obviously... ) and various other sprites
A victory condition: each home at the top of the screen is occupied
A failure condition: loss of all lives due to the frog being squashed or drowned(?)
Now, I am going to cheat slightly in that I am not going to write this directly on the spectrum. It is very awkward to enter code this way and like most people, I would prefer to write code in a modern, PC-hosted text editor. Fortunately, this is suprsingly easy to do - the beauty of Forth is that since so much of Forth is written in Forth, its not that much work to actually run the Forth Interpreter/VM on a PC and have it generate a spectrum snapshot image:
The non-ASCII characters in the title are from the control characters emitted by the "AT" command. The string "Z80" is printed on boot, although you wouldn't normally notice it on a real spectrum since the screen is almost immediately cleared. Interestingly, this is not a string but a cpu id number printed in base 36. This compiler takes a base image ( e.g. a snapshot of white lightning saved from an emulator after start-up ) and an optional source file. If a source file is supplied, text is pulled from this file rather than the command line. I intercept the "PROG" word - which would normally return you to BASIC - to save the snapshot. This can then be loaded into an emulator:
and run by typing "HI". We can also produce a standalone snapshot - i.e. one that will run from the last word defined - by typing "ZAP". The code for this is at https://github.com/tomgrove/figcompiler - its not terribly forgiving and errors will typically lead to lots of meaningless messages, but it's a far better experience than typing code directly into an emulator - or a real spectrum if you are truly dedicated. There are 63 or so words that need to be ported, most of which only implemented by a couple of assembly instructions. These are easy enough to pull out with Ghidra or IDA-Pro.
One thing that came as a suprise to me - and probably anyone else who had used White Lightning back in the day - is the fact that its possible to comment and layout Forth fairly tidily:
( draw the masked frog into the off screen buffer )
: DRAW-FROG /8 SROW !
DUP DUP
/8 SCOL !
FROG-MASK FROG-FRAME SP1 ! 255 SP2 ! GWNDM
FROG-IMAGE FROG-FRAME SP1 ! GWORM ;
( display the buffer )
: DISPLAY-BUFFER 255 SPN ! 4 COL ! 2 ROW ! PUTBLS ;
Well, better than you would get on the spectrum. This isn't really mentioned in the manual ( the language glossary does mention that the '(' character does begin a comment but that is the only mention ) - on the very reasonable grounds that you don't have the memory to be too fussy with how source is laid out. But it's nice that it works out of the box.
Compiling code as multiple source files is possible by using the new image generated as the input to the compiler. This is linking at its most minimal - new code is just appended to end of the dictionary.
Sprites
Now that we can compile Forth in a relatively comfortable manner, we can add some sprites.
IDEAL's Sprite management words allow sprites to be dynamically created, destroyed and queried. They are managed as a link list, much like the Forth dictionary. Unlike the Forth dictionary, sprites can be destroyed which allows the sprite memory to be treated as a simple kind of heap - albeit one that can have a most 255 allocations.
I adapted a few sprites from a Frogger sprite sheet. I'd like to say I entered these in the sprite editor, but in reality I wrote some scruffy code to convert an array of pngs into sprites and directly patch them into the snapshot - something to, to be fair, you could ( not with pngs, mind ) have done yourself back in the day. The layout of the sprite memory and the purpose and location of the key variables are well documented in the manual. The sprites are small enough that I can keep the same resolution and just need to tweak them a bit to convert them to monochrome.
Having got some sprites, what could can we do with them? Most IDEAL operations involve copying sprites between sprite memory and the screen, or between the sprites themselves. These operations can be performed using the simple bitwise operations. These are illustrated below:
The code for this:
: FROG-BLS 2 INK 8 COL ! 0 ROW ! 254 SPN ! PUTBLS 10 COL ! 1 SPN ! PUTBLS 7 INK 0 0 AT ." BLOCK" ;
: FROG-ORS 2 INK 8 COL ! 2 ROW ! 254 SPN ! PUTBLS 10 COL ! 1 SPN ! PUTORS 7 INK 2 0 AT ." OR" ;
: FROG-NDS 2 INK 8 COL ! 4 ROW ! 254 SPN ! PUTBLS 10 COL ! 1 SPN ! PUTNDS 7 INK 4 0 AT ." AND" ;
: FROG-XRS 2 INK 8 COL ! 6 ROW ! 254 SPN ! PUTBLS 10 COL ! 1 SPN ! PUTXRS 7 INK 6 0 AT ." XOR" ;
: FROG-MSK 2 INK 8 COL ! 8 ROW ! 254 SPN ! PUTBLS 10 COL ! 14 SPN ! PUTNDS 1 SPN ! PUTORS 7 INK 8 0 AT ." MASK" ;
The first four of these operations corresponds to taking sprite 1 ( the frog ) and writing it to the screen with Block( PUTBLS ),Or ( PUTORS ) And ( PUTNDS ) and Xor ( PUTXRS ) operations. Block simply replaces the screen data with the sprite data,Or applies a bitwise or, And applies a bitwise and and Xor applies a bitwise xor ( exclusive or). These are all useful operations - Block copying is ( in theory ) fast but destroys the underlying background. Drawing sprites with xor was a popular choice ( and one the maual discusses ) for the reason that re-drawing a sprite with xor would restore the background. The result is a little unsatisfactory, though, as you can see that it effectively inverts the frog sprite when drawn against the log.
The "gold standard" (circe 1984, anyway) for sprite rendering is "masked". This is a combination of And and Or. First a negative image is And'ed with the background ( the mask ) to cut out a hole . Then the sprite is Or'ed on top. This destroy's the background and it will need to be restored in someway - e.g. by saving a copy prior to drawing the sprite. This is what I use in my Frogger game.
Sprites could be copied between both other sprites and the screen. Being able to copy one sprite to another enables a number of techniques, not least a facility for double buffering the screen - a very common technique in spectrum games, although not ubiquitous in 1984. This allows for flicker free animation, since all composition is done away from the screen. This comes at the expense of considerable CPU overhead. It also allows for backgrounds to be assembled off screen from smaller tiles.
A few other transforms are also supported: sprites can be rotated 90 degrees, mirrored, enlarged ( doubling in size only ) and inverted.
Scrolling
Inaddition to sprite operations, IDEAL supplies a number of words to scroll sprites and the screen. Oasis rightly identified support for scrolling as important for game developers: Arcade games of the 80's ( and 90's for that matter ) frequently had vertically or horizontally scrolled play areas, and nothing said "professional game developer" more than writing a game with a large, smooth scrolling background. This is a challenge on the spectrum, which unlike the C64, had no support for hardware scrolling. White lightning support is far from best in class, but it does at least let you set-up scrolling windows fairly easily and means that you can quickly throw together this Frogger-like background:
The code do this:
: DRAW-BG 255 SP2 ! 0 SROW ! 0 SCOL ! ( 255 is the back buffer)
254 SP1 ! GWBLM 2 SROW +! ( first row of logs )
253 SP1 ! GWBLM 2 SROW +! ( second row of logs )
254 SP1 ! GWBLM 2 SROW +! ( first row of logs again)
253 SP1 ! GWBLM 2 SROW +! ( second row of logs again)
250 SP1 ! GWBLM 2 SROW +! ( middle bank )
252 SP1 ! GWBLM 2 SROW +! ( first row of cars )
251 SP1 ! GWBLM 2 SROW +! ( second row of traffic )
252 SP1 ! GWBLM 2 SROW +! ( first row of cars again )
251 SP1 ! GWBLM 2 SROW +! ( second row of cars again)
250 SP1 ! GWBLM 2 SROW +! ( bottom bank )
;
: UPDATE-BG 254 SPN ! WRL1M WRL1M ( scroll first logs 2px left )
253 SPN ! WRR1M WRR1M ( scroll second logs 2px right)
252 SPN ! WRL4M ( scroll first cars 4px left)
251 SPN ! WRR1M ( scroll second cars 1px right )
;
: DISPLAY-BUFFER 255 SPN ! 4 COL ! 2 ROW ! PUTBLS ;
: SCROLL SETUP-SPRITES ( do some initial setup ..)
SETUP-SCR
DRAW-BG
BEGIN
UPDATE-BG ( scroll the lanes )
DRAW-BG ( copy lanes into back buffer )
DISPLAY-BUFFER ( display the buffer )
AGAIN ;
This background is built up in memory from four separately scrolled large sprites ( 250-254) which have been assembled from the smaller sprites above. These are then written into a large back buffer ( 255 ). The GWBLM ( Get-Window-BLock-Memory ) word is used to copy the sprite number in the IDEAL global variable SP1 into the sprite in SP2 at the location given by SROW and SCOL. The UPDATE-BG word scrolls the large sprites using the WRR1M ( WRap-Right-1-Memory ) , WRL1M ( WRap-Left-1-Memory ) and WRL4M ( ..4-Memory) worsd. This illustrates the horizontal scrolling ( with wrap ), but scrolling without wrap is also supported. Horizontal scrolling can only be supported with 1, 4 and 8 ( i.e. character cell ) pixel granularity, so we have to call the *1M words twice to scroll by two pixels. Vertical scrolling ( not used here ) is more flexible, supporting any number of pixels.
Interrupts
Another headline feature of IDEAL is its support for interrupt driven animation. This allowed the programmer to write routines that would be called every 50th of a second by the software interrupt handler. Making use of the interrupt handler was sometimes promoted ( in magazines and books ) as the key to professional looking games - but was regarded as mysterious and difficult to use. The white lightning documentation illustrates how simple it is to setup a simple interrupt handler that will scroll a window of the screen.
: COOL 10 COL' ! 10 ROW' ! 10 LEN' ! 10 HGT' ! ' WRL1V INT-ON ;
For my Frogger game I have not used this, but it could have been used to keep track of time - which is probably a more appropriate use of it than scrolling the background.
Sprite animation
Scrolling sprites was also important for producing smooth animation. The spectrum has no hardware sprites and BASIC only supported printing graphics at character cell granularity. To achieve the sort of visuals that you would expect in an arcade or C64 game, you needed to be able to draw sprites at sub-cellular positions. To achieve this, you need to produce multiple copies of the sprite, each offset by a number of pixels. Then to position a sprite at a pixel position, you divide the position by 8. Rounded down, this gives you the cell position to at which to print the sprite. The remainder gives the id of the sprite to display.
This cute little chap ( Credit: https://www.deviantart.com/omegazero22xx/art/8-bit-Fox-Sheet-541702756 ) has four frames, with each frame shifted two pixels to right of the proceeding one. This lets one place him on the screen at a granularity of 2 pixels, which is enough to provide smooth animation.
And the same here, where both the mask and the sprite are shifted before being written into the back buffer.
Collision detection
To actually make a game, we use a couple of other IDEAL features. This
: IS-COLLIDE
FROG-X @ /8 SCOL !
FROG-Y @ /8 SROW !
100 SP1 ! 255 SP2 ! PWBLM
100 SP2 ! 20 FROG-FRAME SP1 ! 0 SCOL ! 0 SROW ! GWNDM
100 SPN ! SCANM ;
copies the part of the back buffer occupied by the frog to a "collision" sprite ( 100 ). This sprite is then AND'ed with the frog image. The word SCANM then puts a 1 on the stack if there are any non-zero bytes in the sprite. This is very crude, but does give "perfect" collision detection. Its disabled when the frog is on the "safe" banks. When the frog is on the river or the road, we use it to implement the core Frogger game logic:
: MOVE-ON-LOG FROG-Y @ /8 /2 1 AND IF
2 FROG-X +! ELSE -2 FROG-X +!
ENDIF ;
: HITS FROG-Y @ /8 7 > IF
IS-COLLIDE IF 1 DEAD ! ENDIF
ELSE
IS-COLLIDE IF MOVE-ON-LOG ELSE 1 DEAD ! ENDIF
ENDIF ;
i.e kill the frog if in traffic, move the frog if on a log in a direction dictated by the row of the screen on which the frog is sitting. A bit of extra code reads the keyboard, handles a count down timer ( which could have been implemented using the interrupts, but isn't ) and checks for getting frogs home. It all runs at an arcade quality 6-7 Frames per second... which is terrible but the standard for early spectrum games was not high and with a bit of polish, this might just about squeak past as "professional", if not necessarily very good....
Find the full code here: https://github.com/tomgrove/WhiteLightningBlog/tree/master/Frogger
Comments