From GW-BASIC to JavaScript, side by side
The web version isn't a rewrite — it's a faithful port. The game logic is translated statement-for-statement from SNEEKIE.BAS, keeping the original variable names and even the BASIC line numbers (as comments). What actually changed is the thin layer underneath: the handful of things GW-BASIC gave you for free — screen memory, keyboard, sound, GOTO — had to be rebuilt for the browser. This page pairs representative sections and explains that layer; for every BASIC line, use Explained.
The bridge: the screen is the data structure
GW-BASIC POKEd characters straight into the PC's 80×25 text-screen memory and PEEKed it back to find out what was where. There was no separate "snake" or "wall" object — the picture was the state. The port keeps that model exactly, as a 4000-byte Uint8Array (vram): two bytes per cell, character then attribute, addressed by
offset = (row − 1) × 160 + (col − 1) × 2
Because the model is identical, the game code barely changes — only the words POKE and PEEK become the functions poke() and peek(). Everything else on this page follows from rebuilding the rest of GW-BASIC's runtime around that one shared array.
What had to be rebuilt
| The 1988 mechanism | The 2026 replacement |
|---|---|
POKE / PEEK into video memory at &HB000/&HB800 |
poke() / peek() on a 4000-byte Uint8Array
|
LOCATE r,c : PRINT CHR$(n) |
locate(r,c); pc(n) — same cursor, same character codes
|
| the screen refreshes itself (it is video memory) |
a dirty-cell set + requestAnimationFrame redraw, glyphs blitted from an embedded IBM CP437 font
|
INKEY$ / INPUT$ block until a key |
async + a Promise keyboard buffer (keyOrTimeout/waitKey)
|
SOUND freq, ticks |
a Web Audio square-wave oscillator on the same 1/18.2 s clock |
ON LEVEL GOSUB … |
the CFG[] and ENEMY[] dispatch arrays
|
GOTO 510 / RETURN 510 (die) |
throw DEATH, caught around the move loop
|
variables (DEFINT A-Y, Dutch names) |
the same names kept verbatim (T, BTEL, HART, KLAVER…) |
The one deep change: blocking becomes async
GW-BASIC ran top-to-bottom and simply stopped at INKEY$/INPUT$ until you pressed a key. A browser tab can never block like that — so the waiting points became awaits, which forced playLevels() and program() to be async. A small Promise-based keyboard buffer stands in for the BIOS key buffer, and because GOTO doesn't exist, the "jump out and die" lines turn into a thrown DEATH sentinel caught around the loop. Those two tricks — await for input, throw to die — are what let the rest of the code stay a literal translation.
The screen is the data model
PEEK/POKE at video memory and even chooses mono (&HB000) vs colour (&HB800). The port needs only one in-memory grid, so that choice vanishes — vram is a 4000-byte array and poke()/peek() are four lines each.Drawing the fixed frame
LOCATE r,c : PRINT CHR$(n) becomes locate(r,c); pc(n); STRING$(78,…) becomes pcn(196,78); SPC(78) becomes sp(78). The wording was translated to English, but every box-drawing code is untouched.The level loop & the snake seed
FOR LEVEL=1 TO 32 is a for loop; the snake is seeded into T() at the same offsets (2000 tail, 1840 head); ON LEVEL GOSUB becomes one indexed call into the CFG table.Reading a key: blocking INKEY$ becomes async
INKEY$ wait loop (up to Z seconds) can't block a browser, so it becomes a Promise: keyOrTimeout(ms) resolves when a key is buffered or the timer fires. The game loop awaits it — which is why the whole loop is async.Sorting the keypress, and ESC
LEN(A$) distinguishes a 2-character arrow key from a single keypress; the JS checks A$.length. ASC(A$)=27 (ESC) becomes charCodeAt(0) === 27 — which throws the death signal instead of GOTO 510.Turning the head into a screen step
A is the cell the head wants; PEEK(A) → peek(A) reveals what's there.What is in the next cell?
IF D<>n THEN GOTO ladder becomes an if / else if chain on the same character codes — empty, club, heart, stone, smiley, arrow — with the same scoring, spawning and growth.Blocked by a wall or your own tail
blocked = true. Both restore the old heading, dock a little bonus, buzz, and skip the move — no death.
Drawing the body bend
E and the old one F, then append a fresh █ head — carried across condition-for-condition.Shimmer & the per-level hazard
ON LEVEL GOSUB that animates this level's arrows/gates become a for loop and one indexed call into the ENEMY table.Death: GOTO/RETURN 510 becomes a thrown signal
GOTO 510 from the move code, and the hazard routines use RETURN 510 to bail straight out of a GOSUB. JavaScript has neither, so death is throw DEATH caught around the loop — the same "jump out from anywhere", done with exceptions. (The catch sits in the move loop.)Two dispatch tables (ON LEVEL GOSUB)
ON LEVEL GOSUB is a jump table; the port makes it literal. CFG[] holds each level's recipe (speed, item count, bonus step + which maze) and ENEMY[] holds the hazard to animate — both indexed by (LEVEL-1) % 16, so levels 17–32 reuse 1–16.Dropping an item on a random cell
RND → Math.random(). The even-offset trick (land on a character byte, not its colour byte) and the "is it empty?" test are unchanged.Scoring
OP to the score and bump the high score. PRINT USING "######" (a fixed-width number) becomes the helper pu(6, …).Sound: SOUND f,d becomes a Web Audio note
SOUND freq, ticks (ticks of the 1/18.2 s timer) becomes a short square-wave note scheduled on the Web Audio clock — same frequencies, same durations, queued so rapid blips don't overlap. PLAY "mb" just meant "don't pause".Input: INKEY$ scancodes become keydown
INKEY$ as two bytes — CHR$(0) then a scancode — which line 640 takes apart with ASC(MID$(A$,2,1)). The browser's keydown handler manufactures those very same 2-byte strings ('\0H' = up, …), so the loop sees exactly what it saw in 1988.