A reader building chapter 17 from the text alone had to infer that COLLISION 2, BRICKHIT belongs in the setup block rather than the frame loop, and met SCNCLR and QUIT for the first time in the assembly step. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
50 KiB
17. Tutorial: Breakout
This chapter builds a complete game from an empty file: three lives, six rows of bricks, three level layouts, a score, a high score, sound, and a title screen that plays itself. Nothing is loaded from disk — the ball and the paddle are drawn from numbers in the listing, and the wall, the HUD and the messages are characters written into the text grid.
This is what you are building:
The finished listing is
examples/breakout/characters/breakout.bas.
You do not need it to follow along, but it is the same program assembled, and it is worth
opening once you have your own running.
Run it with the SDL build:
$ ./build-akgl/basic examples/breakout/characters/breakout.bas
| Key | Does |
|---|---|
| left / right | move the paddle |
| space | start a game, then launch the ball |
| P | pause |
| Q or escape | quit |
What you will do
- Step 1 — get a program onto the screen, and find out which build you need
- Step 2 — measure the screen and work the geometry out from what you measured
- Step 3 — declare every name the program will use, before anything uses it
- Step 4 — make the ball and the paddle out of sprite data
- Step 5 — build the brick wall out of characters, and keep a grid saying which bricks are still there
- Step 6 — write the main loop and the frame pacing
- Step 7 — read the keyboard without blocking
- Step 8 — move the paddle and keep it on the screen
- Step 9 — move the ball and bounce it off the walls
- Step 10 — find out which brick the ball hit, and break it
- Step 11 — bounce off the paddle at an angle the player chooses
- Step 12 — draw the HUD and the messages
- Step 13 — handle lives, levels and game over
- Step 14 — add sound, on a machine that may not have any
- Step 15 — add a title screen that plays itself
- Step 16 — put the pieces in one file, in the right order
Each step is a piece you can type in and run. Work through them in order, and Step 16 is where they become one program.
Step 1: Get a program onto the screen
Goal: a program that runs, and a build that can draw.
A BASIC program here is a text file. Line numbers are optional, so a file is just statements, one per line:
PRINT "HELLO"
END
HELLO
Save that as first.bas and run it:
$ ./build/basic first.bas
There are two builds and the difference matters for this whole chapter. ./build/basic is
the plain one: it reads and writes text and has no graphics device, no sprites and no
sound. ./build-akgl/basic opens a window and has all three. A game needs the second.
Ask for something that needs a device and you get a refusal naming what is missing rather than a crash:
PRINT "BEFORE"
SPRITE 1, 1, 2
PRINT "AFTER"
BEFORE
? 2 : RUNTIME ERROR SPRITE needs a sprite device and this runtime has none
That is worth seeing once, because it is how the finished game behaves if you start it with the wrong build: it stops at the first graphics verb and tells you why.
For the rest of this chapter, run everything with ./build-akgl/basic.
Two more things to know before writing anything longer.
Every variable carries a type suffix. A# is an integer, A% is a float, A$ is a
string. There is no such thing as a plain A — a bare word is a label. This is different
from Commodore BASIC, where A% is the integer.
+ joins a string to a number. "SCORE " + 40 is "SCORE 40", and "" + N# is how
you turn a number into a string. There is no STR$.
N# = 40
PRINT "SCORE " + N#
END
SCORE 40
Step 2: Measure the screen
Goal: every position in the game derived from the window's real size, so the game fits whatever window and font the player has.
Do not guess the size of anything. Four functions tell you what you have:
| Call | Gives |
|---|---|
RGR(1) |
the window's width in pixels |
RGR(2) |
the window's height in pixels |
RGR(3) |
the width of one character cell, in pixels |
RGR(4) |
the height of one character cell |
RWINDOW(0) |
the text grid's height, in rows |
RWINDOW(1) |
the text grid's width, in columns |
Put them at the very top of the program:
SCW# = RGR(1)
SCH# = RGR(2)
CW# = RGR(3)
CH# = RGR(4)
COLS# = RWINDOW(1)
ROWS# = RWINDOW(0)
RGR(1) is first on purpose. It is the first statement in the program that needs a
graphics device, so on the wrong build this is the line that refuses, and it refuses
before anything else has happened.
Now derive the layout from those numbers rather than writing pixel positions down. The wall is measured in character cells, because it is made of characters:
BRW# = 4
BCOLS# = 10
BROWS# = 6
BTOP# = 4
BLEFT# = (COLS# - (BRW# * BCOLS#)) / 2
A brick is four cells wide and there are ten of them, so the wall is forty cells across.
BLEFT# centres those forty in however many columns there turned out to be. BTOP# is
the row the wall starts on. Integer division truncates, which is exactly what a column
index wants.
The ball and the paddle are sprites, and sprites are positioned in pixels, so the play area is measured in pixels:
TOPY# = 2 * CH#
MAXX# = SCW# - 8
PW# = 144
PY# = 528
LOSEY# = PY# + 32
PSPD# = 10
MSGROW# = 35
TITROW# = 20
TOPY# is the ceiling the ball bounces off, two rows down from the top so it clears the
HUD line. MAXX# is as far right as the ball can go — the window less the ball's own
eight pixels. PW# is the paddle's width and PY# the row of pixels it sits on.
MSGROW# and TITROW# are text rows, counted from the top, for messages.
Cells and pixels are two different coordinate systems, and they meet in exactly one
place — Step 5, where each brick's cell is turned into the rectangle SOLID registers.
Everywhere else, bricks are in cells and everything that moves is in pixels.
Step 3: Declare every name first
Goal: a subroutine that can hand an answer back to its caller.
A GOSUB gets its own scope. That has one consequence you must design around:
- Assigning to a name that already exists outside the subroutine walks up and finds it. The change is visible to the caller.
- Assigning to a name that has never been seen before creates it inside the
subroutine. It disappears at
RETURN, and the caller reads zero — with no error and no warning of any kind.
So a subroutine cannot answer through a name it invented itself. Declare the names first, at the top of the program, and then any routine can write to them:
X# = 0
GOSUB SUBA
PRINT "THE CALLER SEES " + X#
END
LABEL SUBA
X# = 99
RETURN
THE CALLER SEES 99
Take X# = 0 off the top of that program and it prints 0.
This is why the game opens with a block that names everything before the first GOSUB.
Arrays go in the same block. Here is the start of it — the names the later steps refer to
most; Step 16 has the finished block, all 66 of them,
and you can paste that in now if you would rather not come back to it:
DIM BR#(60)
DIM SB#(63)
DIM SP#(63)
DIM LAY$(6)
DIM BSG$(6)
SCORE# = 0
HIGH# = 0
LIVES# = 3
LEVEL# = 1
LEFTN# = 0
BSPD# = 4
STATE# = 0
HELD# = 0
PX# = 0
BX# = 0
BY# = 0
BVX# = 0
BVY# = 0
HIT# = 0
BI# = 0
BR2# = 0
HIT#, BI# and BR2# are the three the rule is really about: they are how the
collision test in Step 10 answers the routine that called it.
DIM belongs at the top and nowhere else. An array declared inside a GOSUB or a
loop takes storage from a pool of 4096 elements that is never given back, so a DIM that
runs repeatedly will eventually end the program:
X# = 0
FOR T# = 1 TO 6000
GOSUB SUBA
NEXT T#
PRINT "OK " + X#
END
LABEL SUBA
DIM LOC#(4)
LOC#(0) = 1
X# = X# + LOC#(0)
RETURN
? 8 : RUNTIME ERROR Array of 4 elements does not fit in the 0 remaining value slots
A plain number costs nothing, though, so you can create as many of those inside a routine as you like:
X# = 0
FOR T# = 1 TO 20000
GOSUB SUBA
NEXT T#
PRINT "OK " + X#
END
LABEL SUBA
LOC# = 1
X# = X# + LOC#
RETURN
OK 20000
Twenty thousand calls, each creating a local, and the program is fine. The rule that
survives is the narrow one: DIM at the top, never inside anything.
Two rules about writing expressions
Both apply everywhere in this chapter, so they are worth fixing in your fingers now.
Parenthesise any expression that mixes + and -. Write (A# - B#) + C#, not
A# - B# + C#:
A# = 10
B# = 3
C# = 2
PRINT (A# - B#) + C#
END
9
Parenthesise a condition that uses more than one AND or OR. Write
IF (A# = 1 AND B# = 2) AND C# = 3 THEN:
A# = 1
B# = 2
C# = 3
IF (A# = 1 AND B# = 2) AND C# = 3 THEN PRINT "ALL THREE"
END
ALL THREE
Both habits are free, and both are being made unnecessary: the parser's additive
precedence is TODO.md §6 item 35 and the second AND is item 36 beside
it. Once those land, the plain forms will mean what they look like they mean, and
parentheses you wrote in the meantime will still be correct.
Step 4: Make the ball and the paddle
Goal: two sprites on the screen, in the right places.
A sprite is a 24-by-21 pattern of pixels. SPRSAV loads one from an integer array of 63
numbers: three bytes per row, twenty-one rows, most significant bit on the left. A bit
that is 1 draws; a bit that is 0 is transparent.
byte 0 byte 1 byte 2
7 6 5 4 3 2 1 0 7 6 5 4 3 2 1 0 7 6 5 4 3 2 1 0
^ column 0 ^ column 23
The ball is an 8-by-8 disc drawn in the top-left corner of the pattern. Putting it there means the sprite's position and the ball's pixel rectangle are the same thing, so no part of the game needs to add an offset to work out where the ball actually is:
. . # # # # . . 60
. # # # # # # . 126
# # # # # # # # 255
The paddle is a solid bar, 24 wide and 6 deep — the full width of a sprite. SPRITE's
x-expand flag doubles it to 48 pixels, and three of those laid end to end make one
144-pixel paddle with no seam.
DIM SB#(63)
DIM SP#(63)
I# = 0
D# = 0
FOR I# = 0 TO 62
SB#(I#) = 0
SP#(I#) = 0
NEXT I#
FOR I# = 0 TO 7
READ D#
SB#(I# * 3) = D#
NEXT I#
FOR I# = 0 TO 17
SP#(I#) = 255
NEXT I#
SPRSAV SB#, 1
SPRSAV SP#, 2
SPRSAV SP#, 3
SPRSAV SP#, 4
SPRITE 1, 1, 2
SPRITE 2, 1, 15, 0, 1, 0
SPRITE 3, 1, 15, 0, 1, 0
SPRITE 4, 1, 15, 0, 1, 0
MOVSPR 1, 108, 40
MOVSPR 2, 48, 80
MOVSPR 3, 96, 80
MOVSPR 4, 144, 80
DATA 60, 126, 255, 255, 255, 255, 126, 60
Read that from the middle. SPRSAV SB#, 1 installs the ball's pattern into slot 1.
SPRITE 1, 1, 2 turns slot 1 on in colour 2. SPRITE 2, 1, 15, 0, 1, 0 turns slot 2 on
in colour 15 with the fourth argument after the colour — the x-expand flag — set to 1.
MOVSPR puts a sprite at a pixel position, measured from its top-left corner.
The finished game writes the same 63 numbers out as DATA, one row per line, with the
bit pattern drawn in a comment beside each, and loads them in a routine called once at
startup. Step 16 has that routine and both patterns
in full; the loops above are the same two patterns spelled shorter so the figure fits on a
page.
Moving the four sprites is one routine, and every coordinate is worked out into a variable before it is passed:
LABEL SHOWSPR
MOVSPR 1, BX#, BY#
MOVSPR 2, PX#, PY#
X2# = PX# + 48
MOVSPR 3, X2#, PY#
X3# = PX# + 96
MOVSPR 4, X3#, PY#
RETURN
Do not write MOVSPR 3, PX# + 48, PY#. MOVSPR reads a leading sign as move by this
much rather than move to here, and an expression beginning with a sign is the relative
form. Computing into X2# first says exactly what you mean. See
Chapter 8.
Step 5: Build the brick wall
Goal: a wall on the screen, and a grid in memory saying which bricks are still standing.
Two things are needed and they are separate: the state (which of the sixty bricks are alive) and the picture (what the player sees). Keep them in step by always redrawing the row you just changed.
The layouts
A level is six strings of ten characters, 1 for a brick and 0 for a hole, stored as
DATA:
LABEL LAY1
DATA "1111111111"
DATA "1111111111"
DATA "1111111111"
DATA "1111111111"
DATA "1111111111"
DATA "1111111111"
LABEL LAY2
DATA "0011111100"
DATA "0111111110"
DATA "1111111111"
DATA "1111111111"
DATA "0111111110"
DATA "0011111100"
LABEL LAY3
DATA "1010101010"
DATA "0101010101"
DATA "1111001111"
DATA "1100110011"
DATA "1011111101"
DATA "0110110110"
READ takes the next DATA item, and RESTORE moves that cursor to a given place.
RESTORE accepts a label, so each layout is simply a named position in the DATA stream
and choosing one is three IFs:
LABEL LOADLAY
N# = MOD((LEVEL# - 1), 3)
IF N# = 0 THEN RESTORE LAY1
IF N# = 1 THEN RESTORE LAY2
IF N# = 2 THEN RESTORE LAY3
FOR R# = 0 TO 5
READ LAY$(R#)
NEXT R#
RETURN
Adding a fourth level is a DATA block and one more IF.
One READ cursor walks every DATA item in the file, in the order they were written,
no matter which routine is doing the reading. If you have two things to load — a font and
a table, say — the one whose DATA comes first in the file must be loaded first, or use
RESTORE to say where to start.
The grid
Turn those strings into a flat array of sixty numbers, and count how many bricks there are while you are at it:
LABEL BUILDW
LEFTN# = 0
FOR R# = 0 TO 5
S$ = LAY$(R#)
GOSUB BUILDR
NEXT R#
RETURN
LABEL BUILDR
FOR C# = 0 TO 9
T$ = MID(S$, C#, 1)
I# = (R# * BCOLS#) + C#
BR#(I#) = 0
IF T$ = "1" THEN BR#(I#) = 1
IF T$ = "1" THEN LEFTN# = LEFTN# + 1
SX# = (BLEFT# + (C# * BRW#)) * CW#
SY# = (BTOP# + R#) * CH#
IF T$ = "1" THEN SOLID I# + 1, SX#, SY#, SX# + (BRW# * CW#), SY# + CH#
IF T$ = "0" THEN SOLID I# + 1
NEXT C#
RETURN
Two things happen per cell, and the second is what Step 10 is built on. BR#() is the
program's own record of which bricks are left. SOLID tells the interpreter, so it can
answer "did the ball hit a brick, and which one" instead of the program working it out.
The id is the array index plus one — SOLID numbers from 1 and the array from 0 — so a
rectangle and its array element are the same brick with no lookup between them. A hole
retires whatever was there, which matters because a level change reuses the ids.
The rectangle is in pixels, and this is the only place the cell grid and the pixel grid meet:
a brick at column C# starts at (BLEFT# + C# * BRW#) * CW# and is BRW# * CW# wide.
MID counts from zero here, unlike Commodore BASIC. BR#(row * 10 + col) is the
brick at that cell; LEFTN# is how many are left, so "is the level finished" is a
comparison rather than a scan of sixty cells.
Drawing a row
CHAR writes a string into the text grid at a row and column. It has one property that
decides how the rest of this section is written: CHAR terminates the row where it
stops. Writing at column 12 erases everything from column 13 to the end of the line.
So do not write bricks one at a time. Build the whole row as a string and write it once, from column 0:
DIM LAY$(6)
DIM BSG$(6)
BSG$(0) = "[##]"
BSG$(1) = "[##]"
BSG$(2) = "[==]"
BSG$(3) = "[==]"
BSG$(4) = "[--]"
BSG$(5) = "[--]"
LAY$(0) = "1111111111"
LAY$(1) = "1111111111"
LAY$(2) = "1101111011"
LAY$(3) = "1111111111"
LAY$(4) = "1011110111"
LAY$(5) = "0110110111"
S$ = ""
T$ = ""
R# = 0
C# = 0
FOR R# = 0 TO 5
GOSUB BUILDROW
PRINT S$
NEXT R#
END
LABEL BUILDROW
S$ = " "
FOR C# = 0 TO 9
T$ = MID(LAY$(R#), C#, 1)
IF T$ = "1" THEN S$ = S$ + BSG$(R#)
IF T$ = "0"<
