SSS: The Silent Soviet Stacker
A block-stacking game written in the number-stacking language Forth, for the Commodore 64. Pause a game in progress then tinker with the live game state in the interpreter.
- README: Jump in and play.
- Design Tour (here): Tutorial, background, reference.
- Tinkering: Make your own dev environment.
- Forth source: Damn dense, beware dragons.
Github readers, click the outline button on the top right.
Front Matter
Intended Audience
SSS is written in Forth, an old and grumpy language I adore. This text is for the Forth-curious: you've toyed around, don't need the stack explained yet again, but haven't written much substance. I link references throughout for many gory details. Lemme know if you can think of more or better!
- Hardware: the C64 wiki, Easy 6502, 6502.org, though if details below intimidate you, try to move past. The interesting stuff is the game, so:
- A walk through the Tetris wiki can't hurt!
- If the above doesn't describe you and you do need stacks explained to you, Starting Forth is your next step. Grab your favorite C64 emulator and a durexForth cart and follow along the examples.
- Refer to the durexForth manual and the ANS Forth glossary which is thorough but opaque.
Background
I was personally drawn to the Commodore 64 only after stumbling on durexForth. They're both lots of fun! Also I only realized after but apparently the C64 hosted the very first commercial Tetris way back in 1988. (After a couple noncommercial ones.) You need to listen to Mr. Wally Beben's sprawling 26-minute opus if you haven't!
In December 2024 I saw someone on 4chan /g/ writing a Tetris in 6502 so I tried my hand at writing one myself. I start them sometimes but don't usually get that far. This one came together mostly fully-formed in a week or two and I've been picking at it to relax ever since.
A tenuous goal is to proselytize Forth by way of a working, well-documented, nontrivial example.
Spec
I've played tons of The Tetris Company (TTC) games, I strongly admire Tetris The Grandmaster (TGM), plus platform constraints and my own preferences make for an eclectic mixed spec, mostly TGM-like:
- Playfield: 10x23.
- Colors: Guideline (cyan I, purple T, etc).
- Spawn: Row 19 (counting from 0), pointy-end-down. All pieces ›bias right.
- Shift: S/F keys, with ›mostly 50Hz [DAS].
- ›Rotate: J/K keys. Flipped JLT are downshifted to lie flat, ISZ have only one vertical.
- ›Kicks: Biased towards rotation. Tries sides then below then below sides. No 2-kick for I. No floorkicks.
- ›Drop: D soft, E hard. No lock delay.
- Hold: L key.
- Generator: ›Reroller queue. 4 slots, 4 tries, giving 3 next piece previews.
- ›Scoring: 8 lines per gravity level. No points.
- Gameover: Blockout only, exits to Forth. No topout: pieces can't move up.
Comment Convention
Forth subroutines are called "words" and operate on a stack of values. I use compact stack comments to fit the cramped C64 screen:
erase ( au-)takes an address and unsigned count -> gives no result but fills the named region of memory with zeroes.curr ( -pts)takes no arguments -> fetches piece position, turn count, and shape index from game state table.piece ( pts-ppppc)takes those three values -> gives 4 block positions and a color code.hit? ( ppppc-f)takes those five values -> gives a boolean flag, so the full phrasecurr piece hit?does what you'd expect.split ( $yyxx -- $xx $yy )sometimes I lean closer to conventional ANS notation when I think the clarity is needed.
Diving In
piece Example
The piece word is the heart of this program. It computes
block positions from a piece description. Read this
interpreter session closely:
\ these comments, wider than the screen, added after.
hex bg
ok \ sets up number base and screen canvas.
1305 2 1 piece .s \ row 19($13) col 5 turns 2 shape J(1)
1304 1305 1306 1404 8 ok \ 4 blocks + orange(8) color
hit? .
0 ok \ false = no collision
1305 2 1 piece plot
ok \ paints rotated orange J piece at top of well.
\ though the 1404 above overwrites part of that canvas.
0 0 0 piece .s hit? . \ bottom left(0) flat(0) I(0) piece.
-2 -1 0 1 3 -1 ok \ coords out of bounds (last -1)
Coords (p)
Packed hex $yyxx coordinates exist in three spaces:
- Blockspace:
0 <= y <= 3, -2 <= x <= 1
$0000= piece origin, where line clear check starts. - Wellspace
th-w:0 <= y <= 22, 0 <= x <= 9
$0000= bottom left of playfield oflanded blocks. - Canvasspace
th-c:0 <= y <= 20, 0 <= x <= 14
$0000= screen row 22 column 13 near bottom left of main 21x19 canvas, corresponding to well origin. Hold and next queue are on the right11 <= x <= 14.
The ›orange value 8 above can be locked into the 4
well positions if not hit?-detected, or plotted on screen.
These use memory indexing n th words: 0 th-w for example
gives the address of the (0,0)th space in the well.
0 th-w h. well h.
cc00 cc00 ok
$0405 th-w h. #45 well + h.
cc2d cc2d ok
$0405 th-c h. -4 40* 5 + colormem + h.
dae2 dae2 ok
0 th-q h. 3 th-q h. ( queue head/tail )
ccee cced ok
Data Shorthands c: b:
Tip
You should probably refer to the source in a separate
tab then Ctrl-F : n: to jump to:
: n: ( *'-*) parse-name evaluate ;
: c: ( u'-) 0 do n: c, loop ;
create colors 7 c: 3 8 6 4 5 2 7
n: (*'-*) asks the interpreter to parse ' and interpret a
word. Since it could do literally anything I notate its stack
effect with *s but it's intended to parse number literals
for compiling data.
c: (u'-) loops u times, calling n: to parse a value and
c, to compile a character (i.e. byte) to memory.
colors (-a), a created word, pushes the address
of a table of 7 color code bytes. Without the shorthand
I could have just written this as:
create colors 3 c, 8 c, 6 c, 4 c, 5 c, 2 c, 7 c,
: >p ( c-p) dup 4* 4* or $f0f and 2 - ;
: b: ( '-) hex 8 0 do n: >p , loop decimal ;
>p (c-p) does precomputation: expanding an 8-bit
character hex $yx into 16-bit $0y0x, then 2 -
›adjusts the origin.
b: ('-) loops 8 times, parsing, expanding, and compiling hex
literals with n: >p ,.
The blocks Table
create blocks \ origin (x/.) at yx=02:
b: 00 01 02 03 02 12 22 32 \ iixi
b: 00 01 02 03 02 12 22 32
b: 03 11 12 13 01 02 12 22 \ jjj
b: 01 02 03 11 02 12 22 23 \ .j
b: 01 11 12 13 02 12 22 21 \ lll
b: 01 02 03 13 03 02 12 22 \ l.
( 4 shapes omitted. )
Expand to show ASCII art illustrations.
3 | . . . . L-piece, pointed down
2 | . . . . in spawn orientation.
1 | .[][][]
0 | .[] o . <- origin
--------
-2-1 0 1 <- blockspace x
0 1 2 3 <- table source x
spawn in well: rotate clockwise:
| I0 . . . . | I1 . .[] . I2 I3 repeat
| . . . . | . .[] .
| . . . . | . .[] .
| . . .[][]()[] . . .| . .() .
| J0 . . . . | J1 . . . . J2 . . . . J3 . . . .
| . . . . | . .[] . . . . . . .[][]
| .[][][] | . .[] . .[] . . . .[] .
| . . . . . o[] . . .| .[]() . .[]()[] . .() .
| L0 . . . . | L1 . . . . L2 . . . . L3 . . . .
| . . . . | .[][] . . . . . . .[] .
| .[][][] | . .[] . . . .[] . .[] .
| . . . .[] o . . . .| . .() . .[]()[] . .()[]
| T0 . . . . | T1 . . . . T2 . . . . T3 . . . .
| . . . . | . .[] . . . . . . .[] .
| .[][][] | .[][] . . .[] . . .[][]
| . . . . .() . . . .| . .() . .[]()[] . .() .
| S0 . . . . | S1 . . . . S2 S3 repeat
| . . . . | .[] . .
| . .[][] | .[][] .
| . . . .[]() . . . .| . .() .
| Z0 . . . . | Z1 . . . . Z2 Z3 repeat
| . . . . | . . .[]
| .[][] . | . .[][]
| . . . . .()[] . . .| . .() .
| O0 . . . . | O1 O2 O3 repeat
| . . . . |
| .[][] . |
| . . . .[]() . . . .|
Important
The values here have some of the biggest ›impact on game-feel.
blocks (-a) gives the address of the table. Again I could
have written this without shorthand as below but the goal was
for the data in the source to be compact and easier to read.
create blocks \ compiled blockspace coords:
-2 , -1 , 0 , 1 , 0 , $100 , $200 , $300 ,
( etc etc )
\ \ zp: w = temp, lsb/msb,x = stack.
\ : w! ( a-) [ lsb ldy,x w sty, msb ldy,x
\ w 1+ sty, inx, 0 ldy,# ] ;
\ : b@ ( p -- p+a@ p ; a+=2.) dup [ clc,
\ w lda,(y) iny, lsb 1+ dup adc,x sta,x
\ w lda,(y) iny, msb 1+ dup adc,x sta,x
\ ] ; \ scan pos from blocks table.
: b@ ( pa-ppa) dup >r @ over + swap r> 2+ ;
\ 7 shapes 4 turns 4 blocks 2 bytes.
: piece ( pts-ppppc) dup >r 4* + 4* 2*
blocks + ( w! ) b@ b@ b@ b@ 2drop r>
colors + c@ ;
For speed sake the table scan words w! (a-) b@ (p-pp) are
written in assembly but for pedagogy sake I present above an
older combined Forth definition of b@ (pa-ppa).
b@ (pa-ppa) takes a piece position p1 $yyxx, an address
in the blocks table a1, and fetches @ and adds + one
cell of the table giving computed block position p2, keeping
the piece position p3=p1, and moving to the next table
address a2=a1+2 ready to fetch the next block.
Note
The Forth idiom >r phrase r> saves a value to the return
stack, allowing you to apply a phrase to the values
underneath it. Another word, >10+>, is named to evoke this
idiom, though it uses swaps for speed.
piece (pts-ppppc) takes a piece position $yyxx, turn
count 0-3, and shape index 0-6, combines t and s to
index into the blocks table, calls b@ to scan out 4
positions, and then a color.
Besides the assembly w! b@, the rest of the program is Forth
and just fast enough for ›mostly full 50fps during play.
Touring the Rest, Part 1: Game Stuff
kbinit
: kbinit ( -) $b80 $28a ! 0 $c6 c! ;
: land ( -- gameover? ) kbinit ( ... ) ;
kbinit stores 3 bytes:
$80configures the KERNAL to repeat all keys, not just the cursors.$brepeat delay of 11 frames.0flushes the key buffer.
land calls it first, before all its other work, to:
- Prevent a keypress from leaking to the next piece,
- Allow the player to leak it anyway, holding it through a 12 frame line clear delay,
- Permit new buffered keypresses during that same delay.
2+3 and 1 should probably be separate words but I like the density.
new
: entropy ( -u) $a1 @ dup 0= + ;
: ?init ( f-) if well size erase
enter 99 sig c! then ;
99 sig c@ <> ?init \ not on redo.
: seeded ( u-) 1 ?init seed ! 5 held!
4 enqueue 5 enqueue 4 enqueue
4 roll enqueue qnext qnext qnext ;
: r ( -) kbinit bg dd
begin step draw until ;
: new ( -) entropy seeded r ;
Starting a new game fetches part of the jiffy clock
to seed the game state then enters the main loop, which is
named r for easy typing by the player.
The dup 0= + phrase in entropy ensures nonzero seed, which
was important for an old xorshift PRNG and harmless
with the current LCG. It's cute and I've grown fond of it.
The initial queue mimics TGM randomizer behavior. First
the queue is [S, Z, S, random IJLT], then after flushing
(qnext three times), the player starts with IJLT, and the
next 3 pieces are less likely to be S or Z (4 or 5).
IJLT are first in the blocks table to enable simple
4 roll enqueue. SZ are adjacent because an earlier version
did 2 roll 4 + ( s4-or-z5 ) held! but I decided to simplify
and also compensate for the init queue having only one Z. Not
very important but that's my rationale anyway.
qnext
\ roll (u-u) 0 <= u2 < u1.
: q? ( si-s/si-) th-q c@ over =
if drop rdrop then ;
: qn ( -) 7 roll 0 q? 1 q? 2 q? 3 q?
enqueue r> rdrop >r ; 12 profile
: qnext ( -) qn qn qn 7 roll enqueue ;
qnext itself reflects the complexity of the TGM algorithm.
It uses rdrop for nonlocal returns: if q? detects a
duplicate roll it returns to qnext to roll again, but if
qn passes all four q? it queues the successful roll and
returns to qnext's caller, though it must take care to dodge
the ›profiling instrument.
An easier version with flags instead of rdrop:
\ macro: ;then = exit then
: reroll ( s-s) drop 7 roll ;
: q? ( sfi-sf) swap if drop 1 ;then
th-q c@ over = ;
: qn ( sf-sf) if reroll 0
0 q? 1 q? 2 q? 3 q? ;then 0 ;
: qnext ( -) 0 1 qn qn qn
if reroll then enqueue ;
Note
This isn't critical path so the codesize and cycle savings
aren't important but I choose to spend the extra rdrop
cognitive load just for its aesthetic, which I'm fond of.
qnext in both versions enqueues only once per call.
go and turnkick
: t@+ ( t-t) turns c@ + 3 and ;
: curr+ ( pt-pts) swap pos @ +
swap t@+ shape c@ ;
: curr+! ( pt-) t@+ turns c! pos +! ;
$-100 constant down
: go ( pt-f) 2dup curr+ piece hit?
if 2drop 1 ;then curr+! #go d! 0 ;
: tk ( pt-) go 0= if rdrop rdrop then ;
: turnkick ( t-) >r 0 r@ tk r@ r@ tk
0 r@ - r@ tk down r@ tk down r@ +
r@ tk down r@ - r@ tk rdrop ;
go adds position and turn offsets to the current player
variables pos and turns, hit-checks the new hypothetical
piece position, and updates those variables if the piece can
move there. turnkick calls it up to six times to implement
wallkicking upon rotation.
Note
A vertical I-piece against the left wall cannot kick. I chose to keep the code simple without an exception for it. I suspect most players will never notice.
›No floorkicks, either.
Touring the Rest, Part 2: Dev Stuff
profile
create bx $d020 eor, $d020 sta, rts,
: profile ( color -- ) here >r dup
lda,# bx jsr, latest >xt jsr, lda,#
bx jmp, r> latest name>string + ! ;
: '' ( "name" -- xt ) ' 6 + @ ;
: prof ( enable-time-profiling? -- )
if $4d else $60 then bx c! ;
The code at bx ("border xor") toggles the
C64 border color register at $d020.
profile adjusts the latest word to point to new code:
lda #color | jsr bx | jsr oldcode | lda #color | jmp bx,
instrumenting the word with border-flipping behavior to
›measure perf. The phrase name>string + addresses
the code field stored after the name.
'' ticks through an instrumented word, recovering the
oldcode execution token from the jsr instruction.
For dumping or executeing or whatever. Example: '' sync 1+ c@ . prints the 215 operand from sync's lda,#
instruction below.
0 prof patches the first instruction at bx to an rts,
disabling it. 1 prof restores the eor.
sync and bg
: sync ( -) [ 215 lda,# $d012 cmp,
-5 bne, ] ; 6 profile
: draw ( -) sync ( ... ) ; 6 profile
The 6 profile here temporarily undoes the 6 profile of
draw, returning to black border while waiting for sync.
Raster line 215 is near the bottom of the well so most draw
updates happen right after the scanline passes. Tradeoffs:
- Hard code
215as above: correct by construction. - Parameterize on 8-bit input: incorrect for lines 0-54 and 256-311.
- Parameterize on 9-bit input: more argument and loop code.
- Actually learn raster interrupts: I don't wanna.
: rect ( awh-a) 0 do 2dup $a0 ( rvbl )
fill swap 40- swap loop drop ;
: bg ( -) 0 $d020 ( black bg+border ) !
11 $286 ( gray fg ) c! page tilemem
38 + 19 21 rect 2+ #10 3 rect drop ;
The reverse-video spaces $a0 make pleasant squares and also
are ignored by the interpreter to make testing and
experimenting easier. Above the main 21x19 canvas is an extra
3x10 area for pieces to rotate into, usually invisible because
the empty well is painted onto it.
redo
marker --sss--
: redo ( -) --sss-- s" sss.fs"
included ( must tco! safe w/ df. ) ;
redo is a deeply magical development convenience.
Consider what happens when you type redo at the interpreter.
It executes the marker --sss--, deleting the program,
including redo itself. The processor doesn't care, it
continues executing the code, now in free memory, pushing the
( addr len ) of the file name and then calling included,
which recompiles the source.
In most Forths, execution would then try to return from
included back to redo code that might have moved, crashing
the system in a fireball. durexForth, however, optimizes the
tail-call into a jump, so included returns directly to the
interpreter safely.
You can then resume a game in progress with the new code
since the variables are outside the dictionary at $cc00,
chosen to overlap unused hi-res graphics, just after v's
buffer.
One consideration: invalid positions etc. in uninitialized
game state will corrupt memory when trying to draw, so
init ›tracks a signature to prevent this.
10 value #10
A durexForth literal : example $1234 ; compiles to 5 bytes
of code: jsr lit | !word $1234 and the lit routine
takes 18 instructions juggle stacks and fetch the value.
However, a phrase $1234 value example compiles 7 bytes:
lda #$12 | ldy #$34 | jmp pushya where pushya takes
only 4 instructions, then a 3-byte jsr on each use, both
faster and potentially smaller. I define words #10 and 4
since those are used pretty often.
Optional Tools recent, bench, patch:
recent checks the cursor column variable $d3 for
word wrap and uses dowords to look through the
dictionary.
bench is based on durexForth's timer, except
with a builtin loop to measure much smaller cycle counts, and
calling its clock fetcher now to avoid the start name
clash. Of note, the second ti modifies the code of the first
ti, keeping a stable stack depth for each jump to the target
xt.
patch: I only just now came up with and so haven't
exercised yet! Beyond the single given example. Heed the
warning, it's a very sharp tool, but any word that calls
(jsrs) most any other word will be safe to patch. The
example unsafe word : bad drop ; only compiles to 2 bytes
inx | rts. Another is : nop ; which is only an rts.
I think these are the only unpatchable cases.
Performance and Tradeoffs
The PAL C64 has a ~19,700 cycle budget per 50Hz frame. Cycle
costs, eyeballing ›1 prof color bands:
| Frame% | While a Piece is in Play |
|---|---|
| 0-7% | KERNAL interrupt. Rolls through the frame, stepping on sync and dropping 1 frame every 4 or 5. |
| 17-19% | piece hit? move/gameover check. |
| 13% | idle draw step, tons of margin. |
| 95% | busy draw step, holding j to rotate every frame. |
Rotation wallkicks check up to 5 extra moves so might eat a second or third frame.
| Frame% | After Landing a Piece |
|---|---|
| 150-250% | land, depending on how much of the well mark scans and how many times qnext re-rolls. Then one of: |
| 105% | #next d! draw if no lines, or: |
| 140% | #well d! draw to show marked lines, then 11 idle %stop frames, then: |
| 160% | sweep to erase marked lines and: |
| 260% | #all d! draw |
Table 2 is less confident, +/- maybe 10%. I might be able to spread work across frames to reduce well and queue flicker but the complexity isn't worth it.
More tradeoffs, ordered by player impact:
Input
The SDF JKL layout keeps my fingers on home row, ready to type Forth. I need configurability in games I play, but I choose not to add it to code whose purpose is to bring me joy.
An option, though, is to edit step yourself and recompile.
Search C64 control codes for "cursor" etc.
A wilder option is to live-patch the code in memory:
'' step dump \ durexforth dumps 64 bytes of machine code.
\ I search the character listing on the right for 's'.
n \ dump 64 more, then I see the 's' at addr $43f3, ymmv.
'z' $43f3 c! \ now 'z' is the shift-left key!
r \ it's pretty hard to play!
's' $43f3 c! \ so I put it back.
Warning
Obviously not for the faint-of-heart. A bad patch will crash
the program, but it saves a recompile! I do this kind of thing
sometimes while developing, it's how I determined the
›215 rasterline above.
Sound
I've never done sound programming before. The SID looks neat, though I wonder if the extra code might strain the already cramped margins. I also enjoy the aesthetic of very little 6502 code. Maybe I'm worrying too much.
The silence, it strikes a certain Soviet charm, no?
I again recommend listening to Mr. Beben's composition for the 1988 Mirrorsoft Tetris. Sets a hell of a mood.
Ghost and Sonic Drop
Implementing the ghost piece in a perfomant way is subtle. Probably I'd check one row per frame, adding some complexity, to say nothing of drawing. I've seen NES block-stackers with the feature but the complexity cost probably outspends my joy budget.
Ghost calculation is necessary for instant drop which I value enough to just do all at once on input, usually taking several frames. Hard drop locks the piece instantly, and sonic drop doesn't, allowing for more player agency at the cost of more inputs in the usual case. Both are valid player actions but sonic requires more subtle code for good game feel: you want lock delay, plus you don't want to accidentally buffer a sonic drop between pieces.
I just went with hard drop, I'm more used to it anyway. Might revisit.
Lock Delay
I'm very fond of lock delay, though I haven't given a huge amount of thought to implementing it. My intuition tells me it's not very simple.
Rotation
Note
Expand the ASCII art section back in the ›blocks table for full details.
The positions encoded are the larger part of a game's "Rotation System" and have large impact on game-feel, but it's perhaps only noticeable to veteran players. SSS uses neither TTC's SRS nor TGM's ARS.
: >p ( ... ) 2 - ;
: b: ( ... ) n: >p , ( ... ) ;
create blocks
( ... )
b: 01 11 12 13
( ... )
\ 3 | . . . . L-piece, pointed down
\ 2 | . . . . in spawn orientation.
\ 1 | .[][][]
\ 0 | .[] o . <- origin
\ --------
\ -2-1 0 1 <- blockspace x
\ 0 1 2 3 <- table source x
: row ( compute from current piece origin ) ;
: mark ( write to wellspace ) ;
: land ( ... ) row mark ( ... ) ;
Negative x in the table borrows from the y coord but when
re-added to the blocks offsets and hit? checked, the final
coords must be in-bounds, preventing memory corruption.
To save a few cycles and a few words of code, mark is
coupled to piece origin, which must then be surrounded or
overlapped by blocks. This ripples into:
Spawn, Kicks
In ARS the I piece biases to the right, closer to the where players usually gap. In both ARS and SRS the 3-wide pieces JLTSZ, however, bias to the left. I chose to uniformly bias right, which is simpler, though it clashes with veteran player muscle memory.
The spawn orientation 0 is pointy-end down and ›J2 L2 T2 are downshifted to lie flat, consistent with ARS and opposed to SRS. Unlike both, I0 I2 also rest on row 0, obviating much of the need for floorkicking, which is unimplemented.
Score
lines count progresses through the gravity frames table but
no scoring beyond that. The performance cost of computing
digits, the complexity cost of BCD, the digits on-screen
interfering with interpreter experimentation, just thinking
about it doesn't spark joy in me.
If you're curious, try lines @ . at the prompt. Forth wants
you to explore it! Makes for an awful Tetris product though.
Density
The source text is constrained to 39 columns to fit on the C64 screen, and for much of development was also within 256 lines. A constraint I loved and whose loss (around the time I expanded the big section 5 comment) I lament.
›Back to top. Some next steps: get your hands dirty with the source (good luck!) or give this thing a play (finally!). Happy stacking, comrade!
























