Sneekie

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.

An old green-screen Sneekie game connected by pixels to a modern browser version.

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.

1

The screen is the data model

The 1988 code points 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.
1988 · GW-BASIC
80 DEFINT A-Y: SCREEN 0: WIDTH 80: CLS: ZORE=0: RANDOMIZE TIMER: DEF SEG=&H0
90 IF PEEK(&H449)=7 THEN VIDEO=&HB000 ELSE VIDEO=&HB800
100 DEF SEG=VIDEO: CLS: DIM T(15000),S(168),B(10),D(80,3)
2026 · JavaScript
/* ---------- VIDEO: 80x25 text VRAM, identical to B000/B800 layout ----------
offset = (row-1)*160 + (col-1)*2 ; even byte = CP437 char, odd byte = attr.
Only attrs 7 (normal) and 15 (bright) occur, exactly like the original. */
const vram = new Uint8Array(4000);
function poke(off, v){
if(off >= 0 && off < 4000 && vram[off] !== v){
vram[off] = v;
dirty.add(off >> 1);
}
}
function peek(off){ return (off >= 0 && off < 4000) ? vram[off] : 0; }
2

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.
1988 · GW-BASIC
110 LOCATE 1, 1: PRINT CHR$(218);STRING$(78,CHR$(196));CHR$(191);
120 LOCATE 2, 1: PRINT CHR$(179);SPC(78);CHR$(179);
130 LOCATE 2,17: PRINT "**** Sneekie **** (c) juli '88 by HerbySoft";
140 LOCATE 22, 1: PRINT CHR$(179);SPC(78);CHR$(179);
150 LOCATE 22, 6: PRINT "10 punten -50 punten Highscore";
160 LOCATE 23, 1: PRINT CHR$(179);SPC(78);CHR$(179);
170 LOCATE 22,55: PRINT "Level Score";
180 LOCATE 23, 6: PRINT "25 punten Steen <ESC> vastgelopen";
190 LOCATE 23,55: PRINT "Lives Bonus";
200 LOCATE 24, 1: PRINT CHR$(192);STRING$(78,CHR$(196));CHR$(217);
2026 · JavaScript
/* 80-210 + 230 + 1090-1130: boot, frame, restart loop */
async function program(){
ZORE = parseInt(lsGet('sneekie.highscore') || '0', 10) || 0; // 80 (persisted)
cls(); // 100
locate(1,1); pc(218); pcn(196,78); pc(191); // 110
locate(2,1); pc(179); sp(78); pc(179); // 120
locate(2,17); ps("**** Sneekie **** (c) July '88 by HerbySoft");
locate(22,1); pc(179); sp(78); pc(179); // 140
locate(22,6); ps('10 points -50 points Highscore'); // 150
locate(23,1); pc(179); sp(78); pc(179); // 160
locate(22,55); ps('Level Score'); // 170
locate(23,6); ps('25 points Stone <ESC> when stuck'); // 180
locate(23,55); ps('Lives Bonus'); // 190
locate(24,1); pc(192); pcn(196,78); pc(217); // 200
poke(3396,1); poke(3556,10); poke(3526,5); poke(3366,3); // 210: legend icons
3

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.
1988 · GW-BASIC
240 FOR LEVEL=1 TO 32
250 FOR I=1 TO 17: LOCATE 3+I,1: PRINT CHR$(179);SPC(78);CHR$(179): NEXT
260 LOCATE 3,1: PRINT CHR$(195);STRING$(78,CHR$(196));CHR$(180)
270 LOCATE 21,1: PRINT CHR$(195);STRING$(78,CHR$(196));CHR$(180)
280 T(1)=2000: T(2)=1840: BTEL=2: ETEL=1
290 POKE T(BTEL),219: POKE T(ETEL),186: POKE T(BTEL)+1,15
300 E=72: F=72: HART=0: KLAVER=0: BONUS=10000: OP=0: GOSUB 1190
310 ON LEVEL GOSUB 2320,2330,2340,2350,2360,2370,2380,2390,2410,2420,2430,2440,2450,2460,2470,2480,2320,2330,2340,2350,2360,2370,2380,2390,2410,2420,2430,2440,2450,2460,2470,2480
2026 · JavaScript
/* 240-1080: FOR LEVEL=1 TO 32 */
async function playLevels(){
for(LEVEL = 1; LEVEL <= 32; LEVEL++){
/* 250-270: playfield + inner borders */
for(let I = 1; I <= 17; I++){ locate(3+I,1); pc(179); sp(78); pc(179); }
locate(3,1); pc(195); pcn(196,78); pc(180);
locate(21,1); pc(195); pcn(196,78); pc(180);
/* 280-300: snake start (head row 12, tail row 13, col 41, moving up) */
T[1] = 2000; T[2] = 1840; BTEL = 2; ETEL = 1;
poke(T[BTEL],219); poke(T[ETEL],186); poke(T[BTEL]+1,15);
E = 72; F = 72; HART = 0; KLAVER = 0; BONUS = 10000; score(0);
/* 310: level config + walls */
CFG[(LEVEL-1) % 16]();
4

Reading a key: blocking INKEY$ becomes async

The biggest structural change. The 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.
1988 · GW-BASIC
420 WHILE+ KLAVER+HART>0
430 A$=INKEY$: Z2=0: Z1=TIMER
440 WHILE+ A$="" AND Z2<Z
450 Z2=TIMER-Z1: A$=INKEY$
460 WEND
2026 · JavaScript
function keyOrTimeout(ms){ // 430-460: INKEY$ poll with Z timeout
return new Promise(res => {
if(kbuf.length){ res(kbuf.shift()); return; }
let to = null;
if(ms !== Infinity) to = setTimeout(() => { kwaiter = null; res(''); }, ms);
kwaiter = () => { if(to) clearTimeout(to); res(kbuf.shift()); };
});
}
function waitKey(){ return keyOrTimeout(Infinity); } // INPUT$(1)
5

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.
1988 · GW-BASIC
490 IF LEN(A$)<>1 THEN GOTO 640
500 IF ASC(A$)<>27 THEN GOTO 910
510 FOR I=1 TO 3
2026 · JavaScript
const A$ = await keyOrTimeout(Z * 1000); // 430-460
if(BONUS > 0) BONUS -= BMIN; // 470
locate(23,73); pu(6,BONUS); // 480
if(A$.length === 1){ // 490
if(A$.charCodeAt(0) === 27) throw DEATH; // 500 -> 510
E = F; score(-BMIN); sound(1000,5); continue; // -> 910 (letter key)
}
if(A$.length === 2) E = A$.charCodeAt(1); // 640
6

Turning the head into a screen step

Identical arithmetic on offsets: a heading becomes a step of ±160 (one row) or ±2 (one column). A is the cell the head wants; PEEK(A)peek(A) reveals what's there.
1988 · GW-BASIC
640 IF LEN(A$)=2 THEN E=ASC(MID$(A$,2,1))
650 A=T(BTEL)
660 IF E=68 THEN GOTO 1080
670 IF E=67 THEN LIVE=LIVE+1: GOTO 1080
680 IF E=80 THEN A=A+160 ELSE IF E=72 THEN A=A-160
690 IF E=77 THEN A=A+2 ELSE IF E=75 THEN A=A-2
700 D=PEEK(A)
2026 · JavaScript
let A = T[BTEL]; // 650
if(E === 68){ skip = true; break; } // 660: F10
if(E === 67){ LIVE++; skip = true; break; } // 670: F9
if(E === 80) A += 160; else if(E === 72) A -= 160; // 680
if(E === 77) A += 2; else if(E === 75) A -= 2; // 690
const d = peek(A); // 700
7

What is in the next cell?

The 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.
1988 · GW-BASIC
710 IF D<>32 THEN GOTO 740
720 POKE T(ETEL),32: POKE T(ETEL)+1,7: SOUND 1500,0.1: ETEL=ETEL+1
730 GOTO 920
740 IF D<>5 THEN GOTO 770
750 L=1: GOSUB 1150: GOSUB 2260
760 OP=25: GOSUB 1190: KLAVER=KLAVER-1: GOTO 920
770 IF D<>3 THEN GOTO 800
780 IF LEVEL>16 THEN L=5: GOSUB 1150: IF K1=1 THEN KLAVER=KLAVER+1
790 L=1: GOSUB 1150: GOSUB 2260: OP=10: GOSUB 1190: HART=HART-1: GOTO 920
800 IF D<>10 THEN GOTO 870
810 TA=A: IF E=80 THEN TA=TA+160 ELSE IF E=72 THEN TA=TA-160
820 IF E=77 THEN TA=TA+2 ELSE IF E=75 THEN TA=TA-2
830 D=PEEK(TA)
840 IF D<>32 THEN GOTO 910
850 POKE TA,10: POKE T(ETEL),32: POKE T(ETEL)+1,7
860 SOUND 1500,0.1: ETEL=ETEL+1: GOTO 920
870 IF D<>1 THEN GOTO 900
880 FOR I=50 TO 1 STEP -1: SOUND 600+75*I,0.35: NEXT
890 OP=-50: GOSUB 1190: L=1: GOSUB 1150: GOTO 920
900 IF D=24 OR D=26 OR D=27 THEN GOTO 510
2026 · JavaScript
const d = peek(A); // 700
let blocked = false;
if(d === 32){ // 710-730: empty
poke(T[ETEL],32); poke(T[ETEL]+1,7); sound(1500,0.1); ETEL++;
} else if(d === 5){ // 740-760: club +25
place(1); sub2260(); score(25); KLAVER--;
} else if(d === 3){ // 770-790: heart +10
if(LEVEL > 16){ place(5); if(K1 === 1) KLAVER++; }
place(1); sub2260(); score(10); HART--;
} else if(d === 10){ // 800-860: push the stone
let TA = A;
if(E === 80) TA += 160; else if(E === 72) TA -= 160;
if(E === 77) TA += 2; else if(E === 75) TA -= 2;
if(peek(TA) !== 32) blocked = true; // 840 -> 910
else {
poke(TA,10); poke(T[ETEL],32); poke(T[ETEL]+1,7);
sound(1500,0.1); ETEL++;
}
} else if(d === 1){ // 870-890: smiley -50
for(let I = 50; I >= 1; I--) sound(600+75*I, 0.35);
score(-50); place(1);
} else if(d === 24 || d === 26 || d === 27){ // 900: arrow = death
throw DEATH;
} else blocked = true; // muur/lijf -> 910
8

Blocked by a wall or your own tail

Anything else means you can't move there. BASIC falls through to line 910; the JS sets blocked = true. Both restore the old heading, dock a little bonus, buzz, and skip the move — no death.
1988 · GW-BASIC
910 E=F: OP=BMIN*-1: GOSUB 1190: SOUND 1000,5: GOTO 1020
2026 · JavaScript
if(blocked){ // 910
E = F; score(-BMIN); sound(1000,5); continue;
}
A short-lived AI comet carries a 1988 BASIC snake game into a glowing browser port.
9

Drawing the body bend

Six cases choose the box-drawing piece for the corner from the new heading E and the old one F, then append a fresh head — carried across condition-for-condition.
1988 · GW-BASIC
920 IF (E=77 AND F=77) OR (E=75 AND F=75) THEN POKE T(BTEL),205: GOTO 980
930 IF (E=80 AND F=80) OR (E=72 AND F=72) THEN POKE T(BTEL),186: GOTO 980
940 IF (E=80 AND F=77) OR (E=75 AND F=72) THEN POKE T(BTEL),187: GOTO 980
950 IF (E=72 AND F=77) OR (E=75 AND F=80) THEN POKE T(BTEL),188: GOTO 980
960 IF (E=80 AND F=75) OR (E=77 AND F=72) THEN POKE T(BTEL),201: GOTO 980
970 IF (E=72 AND F=75) OR (E=77 AND F=80) THEN POKE T(BTEL),200: GOTO 980
980 BTEL=BTEL+1: T(BTEL)=A: F=E: POKE T(BTEL),219
2026 · JavaScript
/* 920-970: body corner char from old+new direction */
if((E===77&&F===77)||(E===75&&F===75)) poke(T[BTEL],205);
else if((E===80&&F===80)||(E===72&&F===72)) poke(T[BTEL],186);
else if((E===80&&F===77)||(E===75&&F===72)) poke(T[BTEL],187);
else if((E===72&&F===77)||(E===75&&F===80)) poke(T[BTEL],188);
else if((E===80&&F===75)||(E===77&&F===72)) poke(T[BTEL],201);
else if((E===72&&F===75)||(E===77&&F===80)) poke(T[BTEL],200);
BTEL++; T[BTEL] = A; F = E; poke(T[BTEL],219); // 980
10

Shimmer & the per-level hazard

The brightness "shimmer" loop and the ON LEVEL GOSUB that animates this level's arrows/gates become a for loop and one indexed call into the ENEMY table.
1988 · GW-BASIC
1000 FOR I=BTEL TO ETEL STEP -2: POKE T(I)+1,15: POKE T(I-1)+1,7: NEXT
1010 ON LEVEL GOSUB 1170,1170,1170,1170,2130,1830,1970,2130,1170,1170,1170,1170,2130,1830,1970,2130,1170,1170,1170,1170,2130,1830,1970,2130,1170,1170,1170,1170,2130,1830,1970,2130
2026 · JavaScript
for(let I = BTEL; I >= ETEL; I -= 2){ // 1000: shimmer
poke(T[I]+1,15); poke(T[I-1]+1,7);
}
ENEMY[(LEVEL-1) % 16](); // 1010
11

Death: GOTO/RETURN 510 becomes a thrown signal

BASIC dies by 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.)
1988 · GW-BASIC
510 FOR I=1 TO 3
520 SOUND 2000,3: SOUND 3000,3: SOUND 4000,3: SOUND 3000,3
530 NEXT
540 IF PLAY(0)<>0 THEN GOTO 540
550 WHILE+ ETEL<=BTEL
560 Z=TIMER
570 Z1=TIMER-Z: IF Z1<0.075 THEN GOTO 570
580 POKE T(ETEL),32: POKE T(ETEL)+1,7: SOUND 1500,0.1
590 ETEL=ETEL+1: OP=BMIN*-1: GOSUB 1190
600 WEND
610 LIVE=LIVE-1: HART=0: KLAVER=0
620 IF LIVE=0 THEN LEVEL=32 ELSE LEVEL=LEVEL-1
630 GOTO 1080
2026 · JavaScript
async function deathSeq(){
for(let I = 1; I <= 3; I++){ // 510-530
sound(2000,3); sound(3000,3); sound(4000,3); sound(3000,3);
}
await playDrained(); // 540
while(ETEL <= BTEL){ // 550-600
await sleep(75); // 560-570 (0.075s)
poke(T[ETEL],32); poke(T[ETEL]+1,7); sound(1500,0.1);
ETEL++; score(-BMIN);
}
LIVE--; HART = 0; KLAVER = 0; // 610
if(LIVE === 0) LEVEL = 32; else LEVEL--; // 620
}
} catch(sig){
if(sig !== DEATH) throw sig;
await deathSeq();
died = true;
break;
12

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.
1988 · GW-BASIC
310 ON LEVEL GOSUB 2320,2330,2340,2350,2360,2370,2380,2390,2410,2420,2430,2440,2450,2460,2470,2480,2320,2330,2340,2350,2360,2370,2380,2390,2410,2420,2430,2440,2450,2460,2470,2480
2320 Z=999: AANTAL= 75: BMIN=10: RETURN
2330 Z=999: AANTAL= 75: BMIN=10: GOSUB 1230: RETURN
2340 Z=999: AANTAL= 75: BMIN=10: GOSUB 1500: RETURN
2350 Z=999: AANTAL= 50: BMIN=10: GOSUB 1400: RETURN
2360 Z=999: AANTAL= 50: BMIN=10: GOSUB 1670: RETURN
2370 Z=999: AANTAL= 50: BMIN=10: GOSUB 1810: RETURN
2380 Z=999: AANTAL= 50: BMIN=10: GOSUB 1920: RETURN
2390 Z=999: AANTAL= 50: BMIN=10: GOSUB 1750: RETURN
2400 '
2410 Z= 0.4: AANTAL=125: BMIN= 5: RETURN
2420 Z= 0.6: AANTAL=125: BMIN= 5: GOSUB 1230: RETURN
2430 Z= 0.6: AANTAL=125: BMIN= 5: GOSUB 1500: RETURN
2440 Z= 0.9: AANTAL=100: BMIN= 5: GOSUB 1400: RETURN
2450 Z= 0.9: AANTAL=100: BMIN= 5: GOSUB 1670: RETURN
2460 Z=1 : AANTAL=100: BMIN= 5: GOSUB 1810: RETURN
2470 Z=1 : AANTAL=100: BMIN= 5: GOSUB 1920: RETURN
2480 Z=1.2: AANTAL=100: BMIN= 5: GOSUB 1750: RETURN
2026 · JavaScript
const noEnemy = () => {}; // 1170
/* 310 + 1010: the two ON LEVEL GOSUB tables (16 entries, used twice) */
const CFG = [
() => { Z = 999; AANTAL = 75; BMIN = 10; }, // 2320
() => { Z = 999; AANTAL = 75; BMIN = 10; lay1230(); }, // 2330
() => { Z = 999; AANTAL = 75; BMIN = 10; lay1500(); }, // 2340
() => { Z = 999; AANTAL = 50; BMIN = 10; lay1400(); }, // 2350
() => { Z = 999; AANTAL = 50; BMIN = 10; lay1670(); }, // 2360
() => { Z = 999; AANTAL = 50; BMIN = 10; lay1810(); }, // 2370
() => { Z = 999; AANTAL = 50; BMIN = 10; lay1920(); }, // 2380
() => { Z = 999; AANTAL = 50; BMIN = 10; lay1750(); }, // 2390
() => { Z = 0.4; AANTAL = 125; BMIN = 5; }, // 2410
() => { Z = 0.6; AANTAL = 125; BMIN = 5; lay1230(); }, // 2420
() => { Z = 0.6; AANTAL = 125; BMIN = 5; lay1500(); }, // 2430
() => { Z = 0.9; AANTAL = 100; BMIN = 5; lay1400(); }, // 2440
() => { Z = 0.9; AANTAL = 100; BMIN = 5; lay1670(); }, // 2450
() => { Z = 1.0; AANTAL = 100; BMIN = 5; lay1810(); }, // 2460
() => { Z = 1.0; AANTAL = 100; BMIN = 5; lay1920(); }, // 2470
() => { Z = 1.2; AANTAL = 100; BMIN = 5; lay1750(); }, // 2480
];
const ENEMY = [noEnemy, noEnemy, noEnemy, noEnemy, sub2130, sub1830, sub1970, sub2130,
noEnemy, noEnemy, noEnemy, noEnemy, sub2130, sub1830, sub1970, sub2130];
13

Dropping an item on a random cell

RNDMath.random(). The even-offset trick (land on a character byte, not its colour byte) and the "is it empty?" test are unchanged.
1988 · GW-BASIC
1150 K1=0: K=INT(RND*2720+480): IF K MOD 2=1 THEN K=K+1
1160 IF PEEK(K)=32 THEN POKE K,L: K1=1
1170 RETURN
2026 · JavaScript
/* 1150: drop item L on a random empty cell (rows 4-20) */
function place(L){
K1 = 0;
let K = Math.trunc(rnd()*2720 + 480);
if(K % 2 === 1) K++;
if(peek(K) === 32){ poke(K, L); K1 = 1; }
}
14

Scoring

Add OP to the score and bump the high score. PRINT USING "######" (a fixed-width number) becomes the helper pu(6, …).
1988 · GW-BASIC
1190 ZCORE=ZCORE+OP: LOCATE 22,73: PRINT USING "######";ZCORE
1200 IF ZCORE>ZORE THEN ZORE=ZCORE: LOCATE 22,46: PRINT USING "######";ZORE
1210 RETURN
2026 · JavaScript
/* 1190: score OP points, track highscore */
function score(OP){
ZCORE += OP;
locate(22,73); pu(6, ZCORE);
if(ZCORE > ZORE){
ZORE = ZCORE;
locate(22,46); pu(6, ZORE);
saveHighscore();
}
}
15

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".
1988 · GW-BASIC
2260 PLAY "mb": SOUND 2500,0.1: SOUND 3500,0.1: SOUND 5000,0.1: RETURN
2026 · JavaScript
/* ---------- AUDIO: GW-BASIC SOUND f,d (d in 1/18.2s clock ticks) ---------- */
let actx = null, qEnd = 0;
let muted = lsGet('sneekie.muted') === '1';
function ensureAudio(){
if(!actx){
try{ actx = new (window.AudioContext || window.webkitAudioContext)(); }catch(_){ }
}
if(actx && actx.state === 'suspended') actx.resume()?.catch(() => {});
}
function sound(freq, ticks){
if(!actx) return;
const dur = ticks / 18.2;
const t0 = Math.max(actx.currentTime, qEnd);
if(t0 - actx.currentTime > 3) return; // cap the backlog
if(!muted){
const o = actx.createOscillator(), g = actx.createGain();
o.type = 'square'; o.frequency.value = Math.min(freq, 12000);
const v = 0.05;
g.gain.setValueAtTime(v, t0);
g.gain.setValueAtTime(v, Math.max(t0, t0 + dur - 0.004));
g.gain.linearRampToValueAtTime(0.0001, t0 + dur);
o.connect(g); g.connect(actx.destination);
o.start(t0); o.stop(t0 + dur + 0.02);
}
qEnd = t0 + dur;
}
async function playDrained(){ // 540: IF PLAY(0)<>0 GOTO 540
if(!actx) return;
while(qEnd > actx.currentTime) await sleep(25);
}
16

Input: INKEY$ scancodes become keydown

DOS returned an arrow from 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.
1988 · GW-BASIC
640 IF LEN(A$)=2 THEN E=ASC(MID$(A$,2,1))
2026 · JavaScript
addEventListener('keydown', e => {
if(bootActive){
ensureAudio();
if(bootWaiting) startBootFromGesture();
else if(bootStarted) bootSkip = true;
if(e.key.startsWith('Arrow') || e.key === 'F9' || e.key === 'F10' || e.key === ' ' || e.key === 'Escape' || e.key === 'Enter') e.preventDefault();
return;
}
ensureAudio();
let s = null;
switch(e.key){
case 'ArrowUp': s = '\0H'; break; // scan 72
case 'ArrowDown': s = '\0P'; break; // scan 80
case 'ArrowLeft': s = '\0K'; break; // scan 75
case 'ArrowRight': s = '\0M'; break; // scan 77
case 'F9': s = '\0C'; break; // scan 67 (extra life — shh!)
case 'F10': s = '\0D'; break; // scan 68 (skip level — shh!)
case 'Escape': s = '\x1b'; break;
case 'Enter': s = '\r'; break;
default: if(e.key.length === 1) s = e.key;
}
if(s !== null){
if(e.key.startsWith('Arrow') || e.key === 'F9' || e.key === 'F10' || e.key === ' ' || e.key === 'Escape' || e.key === 'Enter') e.preventDefault();
pushKey(s);
}
});
A blocky Sneekie snake leaping through a pixel portal from an old CRT into a modern browser.