Files
akbasic/docs/17-tutorial-breakout.md
Andrew Kesterson 05f241aca1 Stop a scalar created inside a scope costing value-pool slots
A scalar now lives in the variable record (`akbasic_Variable::inlinevalue`)
rather than drawing from the value pool, so a `GOSUB` local, a `FOR` counter and
a `DEF` parameter cost nothing at all.

The pool is a bump allocator with no free, and its comment justified that with
"nothing in BASIC destroys a variable". Scope exit does: it marks the variable
slot unused, `new_variable()` memsets the slot it hands back -- clearing
`values` -- and `variable_init()` therefore took *fresh* slots for a variable
whose old ones were still counted. Every scope that created a local leaked, with
no diagnostic until the pool ran dry on whichever line happened to be unlucky.

Six thousand `GOSUB`s creating one local used to die on the 4091st at `LOC# = 1`
with "Array of 1 elements does not fit in the 0 remaining value slots". They now
run. A `DEF` called eight thousand times used to die between the four and five
thousandth -- the leaking slot was the call scope's parameter, which is a scalar
-- and both forms now run. A game creating one name per tick was dead in half a
minute; the Breakout in examples/ was, after twenty-five seconds.

**A `@` name is the one exclusion, and it is the whole of it.** A structure or a
pointer to one keeps pool storage, because a pointer into a record outlives the
scope that DIMmed it -- docs/16-structures.md says nothing is reclaimed and
`prev_environment()` relies on it. The name suffix is the right test rather than
`structtype`, which the DIM path sets *after* calling `variable_init()`. A local
array therefore still leaks, deliberately, and is now the narrow rule the
tutorial teaches.

`SWAP` needed the other half: it copies whole variable records, so the `values`
pointer that came over named the other variable's inline slot -- which by then
held this variable's own old value -- and SWAP silently did nothing. Caught by
tests/language/housekeeping/verbs.bas, which is the golden corpus earning its
keep.

tests/value_pool.c is the new coverage. It asserts the mechanism as well as the
consequence: a later change that moved arrays inline too would pass every
behavioural case and quietly break the pointer guarantee. The sharpest case
takes the pool's whole 4096 slots in four arrays after two hundred scope
entries, so one leaked slot has nowhere to go.

Chapter 17 Step 3 taught "declare every name at the top" and no longer needs to.
It now teaches what is still true -- a name first seen inside a subroutine dies
at RETURN, so a routine cannot answer its caller through one -- and its
demonstration is the array case, which still fails.

TODO.md section 6 item 30 and section 9 item 1, both struck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:47:49 -04:00

22 KiB

17. Tutorial: Breakout

This chapter builds a complete game — three lives, six rows of bricks, three level layouts, a high score and an attract mode that plays itself — out of nothing but the verbs in the earlier chapters. The finished listing is examples/breakout/characters/breakout.bas, and it is worth having open beside this.

The ball and the paddle are sprites; the bricks, the HUD and the messages are characters in the text grid. That split is the first decision and everything else follows from it, so it is Step 1.

Run the finished game 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

Chapter 18 builds the same game again out of sprite artwork, and takes a completely different shape. Read this one first: it is the smaller of the two and it is where the rules that constrain both are explained.


Step 1: Decide what is a sprite and what is a character

Two layers of this interpreter are redrawn from state it keeps for you, every frame, without your program doing anything:

  • the text grid, repainted row by row by the akgl sink, and
  • the sprites, walked slot by slot after it.

The drawing verbs are not one of them. DRAW, BOX, CIRCLE and PAINT go straight into the renderer's back buffer, and the text layer paints over the whole window on its way past — so in the standalone SDL build nothing you draw with them is ever visible. That is recorded as a defect (TODO.md §9 item 3) rather than a design, but it is the interpreter you have.

So this game uses no drawing verb at all. Anything that has to move smoothly is a sprite; anything that can live on a 16-pixel grid is text. Ball and paddle move; bricks, score and messages do not.

If you want artwork on the screen, that rule flips and the whole architecture changes with it. That is Chapter 18.

Step 2: Measure the screen once, at the top

CW# = 16
CH# = 16
SCW# = RGR(1)
SCH# = RGR(2)
COLS# = SCW# / CW#
ROWS# = SCH# / CH#

RGR(1) and RGR(2) are the window in pixels — see Chapter 6 — and the game asks for them rather than assuming 800 by 600, so it survives a host that opens a different window.

The cell size is a constant and has to be, which is the one thing here you cannot derive. WINDOW knows the grid, but the standalone frontend's tee sink never offers it (TODO.md §9 item 3 again), so a program has no way to ask. 16 by 16 is the bundled C64_Pro_Mono at 16 points. Change the font or the point size and change these two with it; everything below is derived from them, including the ball's speed relative to the wall.

Lay the rest of the geometry out in the same block — the wall in cells, the play area in pixels:

BRW# = 4
BCOLS# = 10
BROWS# = 6
BTOP# = 4
BLEFT# = (COLS# - (BRW# * BCOLS#)) / 2
TOPY# = 2 * CH#
MAXX# = SCW# - 8
PW# = 144
PY# = 528

A brick is four cells wide, so ten of them are forty cells, and BLEFT# centres that in whatever COLS# turned out to be. Integer division truncates here, which is exactly what a cell index wants.

Step 3: Declare the names a subroutine has to answer through

A GOSUB gets its own scope. Assignment walks up the chain and finds an outer name, but a name first seen inside a subroutine is created in that subroutine and dies at RETURN — so a routine cannot answer its caller through a name it invented itself.

That is why the game declares this block before anything calls anything:

DIM BR#(60)
DIM LAY$(6)
DIM BSG$(6)
SCORE# = 0
LIVES# = 3
BX# = 0
BY# = 0
HIT# = 0
BI# = 0

HIT# and BI# are how HITTEST (Step 8) answers the routine that called it. Declared out here they are one variable the whole program shares; left to HITTEST, they would be two that vanish at RETURN and the caller would read zeros for ever, with no diagnostic of any kind. Chapter 4 has the scope rules.

Creating a scalar inside a scope is free, so nothing else has to be declared up front:

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. This did not use to work. A scalar drew storage from the same 4096-slot pool an array does, scope exit gave back the variable but not its storage, and a program creating one name per tick died in about half a minute — naming whichever line was unlucky rather than the line that was wrong. It is what killed the first version of this game after twenty-five seconds. TODO.md §6 item 30 has the history; tests/value_pool.c is what keeps it fixed.

An array is different, and still costs slots for good. DIM inside a subroutine takes pool storage that never comes back, because a pointer into a record can outlive the scope that declared it — see Chapter 16:

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

So the rule that survives is narrow: DIM at the top, not in a loop. Which is where you would have put it anyway.

Step 4: Build the wall, and write a row whole

Levels are DATA, one string per row, 1 for a brick and 0 for a hole:

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"

RESTORE takes a label — it wants a line number and a label evaluates to one — so a layout is just a named place in the DATA stream, and adding a fourth level is a block and one IF:

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

Now draw it — and here is the second rule that shapes this listing:

CHAR terminates the row where it stops. A row of the grid is a C string, so writing at column N erases everything from N+1 on, and writing past the terminator of a short row draws nothing at all. There is no way to poke one character into the middle of a row and leave the rest alone.

So a row is rebuilt whole and written from column 0, in one CHAR:

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" THEN S$ = S$ + "    "
NEXT C#
RETURN
     [##][##][##][##][##][##][##][##][##][##]
     [##][##][##][##][##][##][##][##][##][##]
     [==][==]    [==][==][==][==]    [==][==]
     [==][==][==][==][==][==][==][==][==][==]
     [--]    [--][--][--][--]    [--][--][--]
         [--][--]    [--][--]    [--][--][--]

That block runs anywhere, with or without a graphics device, because PRINT and CHAR are building the same string. In the game the last line is CHAR 1, 0, R2#, S$ instead, and the leading spaces are " " * BLEFT#.

The rows are told apart by shape, not by colour. CHAR parses a colour argument and ignores it; the sink draws in one colour. [##], [==] and [--] are what makes a 60-point row look different from a 10-point one.

Killing a brick clears its cell and redraws that one row:

LABEL KILLBR
BR#(BI#) = 0
LEFTN# = LEFTN# - 1
PTS# = (BROWS# - BR2#) * 10
SCORE# = SCORE# + PTS#
GOSUB DRAWBR
GOSUB DRAWHUD
IF LEFTN# < 1 THEN STATE# = 2
RETURN

LEFTN# is counted up when the wall is built and down when a brick goes, so "is the level clear" is a comparison rather than a scan of sixty cells.

Step 5: Make the ball and the paddle

SPRSAV takes an integer array of 63 bytes: three per row, twenty-one rows, most significant bit leftmost. A clear bit is transparent — see Chapter 8.

Two patterns are all this game needs. The ball is an 8 by 8 disc in the top-left corner of the pattern, so the sprite's position and its pixel rectangle are the same thing and no offset arithmetic is needed anywhere. The paddle is a solid 24 by 6 bar with SPRITE's x-expand bit set, which makes it 48 wide; three of them laid end to end are one 144-pixel paddle.

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

The three segments meet with no seam, which is the whole point of the arrangement. The game writes the same 63 bytes out as DATA a row at a time, with the bit pattern drawn in a comment beside it; the loops above are the same two patterns spelled shorter so the figure fits on a page.

Moving them is four MOVSPRs, and each coordinate is worked out into a variable first:

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 — see Chapter 8 — and an expression that starts with one is the relative form. Computing into X2# first is unambiguous.

Sprites are positioned by their top-left corner in device pixels, so ball, paddle and walls are all plain pixel arithmetic. Only the bricks are in cells, and exactly one routine converts between the two (Step 8).

Step 6: Write the main loop as a GOTO loop

LABEL TICK
GOSUB READKEY
IF STATE# <> 0 THEN GOTO DISPATCH
IF PAUSED# = 1 THEN GOTO TICKEND
GOSUB MOVEPAD
IF HELD# = 1 THEN GOSUB HOLDBAL
IF HELD# = 0 THEN GOSUB MOVEBAL
GOSUB SHOWSPR
LABEL TICKEND
SLEEP 0.02
GOTO TICK

LABEL DISPATCH
IF STATE# = 1 THEN GOTO LOSTLIF
IF STATE# = 2 THEN GOTO LEVELUP
IF STATE# = 3 THEN GOTO BYE
IF STATE# = 5 THEN GOTO NEWGAME
STATE# = 0
GOTO TICK

DO ... LOOP would be the natural way to write this and it is the wrong one. Every loop and every GOSUB pushes one of 32 scopes, and a state change that jumped out of a DO — losing a life, clearing a level — would leak its scope every single time. Thirty-two lives later the program stops. A GOTO loop pushes nothing at all, so the deepest chain in play is the six GOSUBs of a brick hit.

Nothing here GOSUBs into the state changes either. They are branch targets that run and then GOTO TICK, so the stack is empty again by the time the next frame starts.

SLEEP 0.02 is the frame pacing, and it does not block: it records a deadline and the step loop declines to advance the program until the clock reaches it, so the host goes on drawing frames and pumping the keyboard the whole time. Fifty ticks a second is what that asks for. Chapter 18 needs a much sharper instrument than this one and builds it out of TI#.

Step 7: Read the keyboard through a countdown

GET is the only non-blocking read there is. There is no key-up event and no held-key state — what you get instead is the host's own key repeat arriving as ordinary key-down events, about one a tick while a key is held down.

So drain the queue every tick, and let a keystroke refill a countdown:

LABEL READKEY
FOR KI# = 1 TO 8
  GET K#
  IF K# <> 0 THEN GOSUB HANDKEY
NEXT KI#
RETURN

LABEL HANDKEY
IF K# = 1073741904 THEN PDIR# = 0 - 1
IF K# = 1073741904 THEN PDEC# = 12
IF K# = 1073741903 THEN PDIR# = 1
IF K# = 1073741903 THEN PDEC# = 12
IF K# = 32 THEN GOSUB KEYSPC
IF K# = 112 THEN GOSUB KEYPAU
IF K# = 113 THEN STATE# = 3
IF K# = 27 THEN STATE# = 3
RETURN
LABEL MOVEPAD
IF PDEC# < 1 THEN GOTO PADCLMP
PDEC# = PDEC# - 1
PX# = PX# + (PDIR# * PSPD#)
LABEL PADCLMP
IF PX# < 0 THEN PX# = 0
M# = SCW# - PW#
IF PX# > M# THEN PX# = M#
RETURN

The paddle coasts to a stop over twelve ticks when the repeats stop, so held reads as held and tapped reads as a nudge — with no held-key state anywhere. 1073741903 and 1073741904 are the right and left arrows; the codes are the host's, and Chapter 12's GET entry says where they come from.

Note 0 - 1 rather than -1. Unary minus on a literal is fine, but the habit of writing the subtraction out is worth forming here: this parser reads a - b + c as a - (b + c), so every mixed + and - in the game is parenthesised as a rule rather than where it happens to matter. It also matches only one unparenthesised AND or OR per expression. Both are in Chapter 13.

Step 8: Move the ball one axis at a time

LABEL MOVEBAL
OX# = BX#
BX# = BX# + BVX#
IF BX# < 0 THEN GOSUB WALLL
IF BX# > MAXX# THEN GOSUB WALLR
GOSUB XBRICK
OY# = BY#
BY# = BY# + BVY#
IF BY# < TOPY# THEN GOSUB WALLT
GOSUB YBRICK
GOSUB PADHIT
IF BY# > LOSEY# THEN STATE# = 1
RETURN

X first and then Y, each move tested on its own, so a ball arriving at the corner of a brick reverses one axis rather than both. OX# and OY# remember where it came from, which is what a collision backs it out to.

The brick test converts a pixel to a cell, and it is the only place in the program that does:

LABEL HITTEST
HIT# = 0
RC# = TY# / CH#
IF RC# < BTOP# THEN RETURN
RB# = RC# - BTOP#
IF RB# > (BROWS# - 1) THEN RETURN
CC2# = TX# / CW#
IF CC2# < BLEFT# THEN RETURN
CB# = (CC2# - BLEFT#) / BRW#
IF CB# > (BCOLS# - 1) THEN RETURN
BI# = (RB# * BCOLS#) + CB#
BR2# = RB#
IF BR#(BI#) = 1 THEN HIT# = 1
RETURN

Callers hand it a point in TX#/TY# and read HIT#, BI# and BR2# back out — which works only because Step 3 created all three outside the routine.

Test the ball's leading edge, not its centre:

LABEL XBRICK
TX# = BX#
IF BVX# > 0 THEN TX# = BX# + 7
TY# = BY# + 4
GOSUB HITTEST
IF HIT# = 0 THEN RETURN
BX# = OX#
BVX# = 0 - BVX#
GOSUB KILLBR
RETURN

A ball tested by its centre sinks four pixels into a brick before it turns, and it looks like the brick was hit late. The cost of the leading edge is that a brick clipped at the very corner can be missed by up to seven pixels' worth of ball; that is the better trade of the two, and it is worth knowing which one you took.

Step 9: Make the paddle bounce mean something

LABEL PADHIT
IF BVY# < 1 THEN RETURN
BB# = BY# + 8
IF BB# < PY# THEN RETURN
IF BY# > (PY# + 10) THEN RETURN
RX# = PX# + PW#
IF (BX# + 8) < PX# THEN RETURN
IF BX# > RX# THEN RETURN
BY# = PY# - 8
BVY# = 0 - BSPD#
Z# = ((BX# + 4) - PX#) / (PW# / 5)
IF Z# < 0 THEN Z# = 0
IF Z# > 4 THEN Z# = 4
PVX# = BVX#
BVX# = (Z# - 2) * 3
IF BVX# <> 0 THEN GOTO PADAIM
BVX# = 2
IF PVX# < 0 THEN BVX# = 0 - 2
LABEL PADAIM
GOSUB BEEPP
RETURN

Five zones across the face and the zone decides the angle: Z# is 0 to 4, so the new horizontal speed is -6, -3, 0, +3, +6. Where you catch it decides where it goes, which is the entire game.

The dead-centre case needs its own handling. A ball with no sideways speed rises and falls down one column for ever, which is how attract mode used to hang itself — so the middle zone keeps the direction the ball came in on and gives it a shallow angle instead of zero.

There is a watchdog for the same failure in a subtler form. Ten seconds without touching a brick means the ball has found an orbit that misses the wall, so it gets a new angle — at the next paddle bounce, never in mid-air. Changing it in flight looks exactly like the ball hitting something that is not there:

LABEL UNSTICK
NUDGE# = 0
STALL# = 0
RMAX# = 4
GOSUB RANDOM
BVX# = (RND# * 3) - 6
IF BVX# = 0 THEN BVX# = 3
RETURN

RANDOM is a linear congruential generator, because this dialect has no RND — and no INT, SQR, ASC or TIMER either. TI# is jiffies off the host's clock and is uptime rather than zero-based, which makes it a fine seed:

LABEL RANDOM
SEED# = MOD(((SEED# * 1103515245) + 12345), 2147483648)
RND# = MOD((SEED# / 65536), RMAX#)
RETURN

The multiply stays inside a 64-bit integer for any seed under 2^31, and the answer comes from the middle bits because the low bits of a power-of-two modulus barely move. Integer division truncates for free, which is the INT you do not have.

Step 10: The HUD and the messages

Same rule as the wall: build the whole line, write it once.

V# = 0
P$ = ""
H$ = ""
SCORE# = 1250
LIVES# = 3
LEVEL# = 2
HIGH# = 9900
V# = SCORE#
GOSUB PAD6
H$ = "  SCORE " + P$
H$ = (H$ + "   LIVES ") + LIVES#
H$ = (H$ + "   LEVEL ") + LEVEL#
V# = HIGH#
GOSUB PAD6
H$ = (H$ + "   HIGH ") + P$
PRINT H$
END

LABEL PAD6
P$ = "" + V#
DO WHILE LEN(P$) < 6
  P$ = "0" + P$
LOOP
RETURN
  SCORE 001250   LIVES 3   LEVEL 2   HIGH 009900

"" + V# is how a number becomes a string: + concatenates a string with a number, and that is the conversion — see Chapter 5. Every + after the first is parenthesised, for the reason in Step 7.

A centred message is the same idea, and needs no padding on the right because CHAR terminating the row is what erases the rest of it:

LABEL SHOWAT
L# = LEN(MSG$)
C2# = (COLS# - L#) / 2
S$ = MSG$
IF C2# > 0 THEN S$ = (" " * C2#) + MSG$
CHAR 1, 0, MROW#, S$
RETURN

LABEL CLRAT
CHAR 1, 0, MROW#, " "
RETURN

Clearing a message is a single space written at column 0 — one character, and the terminator behind it takes the rest of the row with it.

Step 11: Ask for sound once, then believe the answer

SOUND refuses on a machine with no audio device, and an untrapped refusal ends the game. Ask once at startup, record the answer, and never ask again:

SND# = 1
TRAP NOAUDIO
SOUND 1, 2000, 1
TRAP
IF SND# = 1 THEN PRINT "SOUND IS AVAILABLE"
IF SND# = 0 THEN PRINT "NO AUDIO DEVICE, PLAYING SILENTLY"
END

LABEL NOAUDIO
SND# = 0
RESUME NEXT
NO AUDIO DEVICE, PLAYING SILENTLY

That output is the stdio build, which has no devices at all. On the SDL build the same program prints the other line. TRAP with no argument disarms the handler again — leave it armed and the next refusal anywhere in the game is silently swallowed. See Chapter 4.

After that every sound is guarded by the flag it recorded:

LABEL BEEPB
IF SND# = 1 THEN SOUND 1, 12000, 2
RETURN

SOUND's frequency argument is a SID register value, not hertz — the pitch is register * 1022730 / 16777216, and Chapter 7 has the arithmetic. Work the notes out once and write them down in a comment beside the numbers.

Step 12: Give it an attract mode, and test with it

The title screen plays itself, and this is not decoration — it is how the game gets tested without a hand on the keyboard. DEMO# is the only difference between the demo and a real game:

LABEL DEMOPAD
TX# = (BX# + 4) - (PW# / 2)
TX# = TX# + DOFF#
D# = TX# - PX#
IF D# > PSPD# THEN D# = PSPD#
M# = 0 - PSPD#
IF D# < M# THEN D# = M#
PX# = PX# + D#
RETURN

LABEL DEMOAIM
RMAX# = 81
GOSUB RANDOM
DOFF# = RND# - 40
RETURN

The demo paddle tracks the ball but aims off-centre by a random amount, refreshed at every bounce, so it plays like something with a hand on it and occasionally misses. A demo that tracks perfectly never loses a ball and therefore never tests losing one.

Leave it running for two minutes and watch the score climb: that exercises the ball, the bricks, the level change and the speed-up — and it is the only thing that exercises them together, for long enough. Every defect this game found took thousands of ticks to show itself, and a green test suite proved nothing about any of them.

The machine does not get on the scoreboard. Attract mode keeps score so the wall behaves, but only a player's score is ever the high one:

LABEL HISCORE
IF SCORE# > HIGH# THEN HIGH# = SCORE#
RETURN

There is nowhere to save it. There is no disk in this interpreter — see Chapter 9 — so the high score lasts as long as the process does.

The rules this listing never breaks

Every one of these was learned by breaking it. They are the checklist to write your own game against:

Rule Because Where
DIM at the top, never in a loop An array created inside a scope costs pool slots for good; 4096 ends the run. A scalar is free Step 3
Build a text row whole and write it from column 0 CHAR terminates the row where it stops Step 4
Tell rows apart by shape, not colour CHAR ignores its colour argument Step 4
Compute a MOVSPR coordinate into a variable first A leading sign means move by, not move to Step 5
Loop the game with GOTO, not DO 32 scopes, and jumping out of a loop leaks one Step 6
Parenthesise every mixed + and - a - b + c parses as a - (b + c) Step 7
One AND or OR per expression unless parenthesised The parser matches one Step 7
Keep a line under 32 tokens The 33rd kills the interpreter with a stack trace, not a BASIC error you can TRAP
Span a loop across lines, and never FOR I = 1 TO 1 A whole loop on one line does not loop; equal bounds run zero times Ch 13
Probe for a device once and remember An untrapped refusal ends the program Step 11

Where to go next

  • Chapter 18 builds Breakout again with sprite artwork, powerups and a coloured HUD. Every architectural decision comes out different, and the chapter is about why.
  • Chapter 13 is the full list of what this dialect does differently from BASIC 7.0.
  • Chapter 14 is the interpreter itself — the step loop, the pools and how to debug a program that stops for no visible reason.
  • TODO.md §6 and §9 carry the defects this game found, each with a reduction that fits on a screen and what a fix would touch.