Es gibt Momente, in denen man nicht einfach nur Code schreiben will – man will fühlen, wie es war, zum ersten Mal vor einem Computer zu sitzen. Der Bildschirm leuchtet blau. Die Cursor blinkt. Und dann tippt man:

PRINT "HELLO WORLD

Drückt ENTER. Und die Maschine antwortet.

Genau dieses Gefühl hat mich dazu gebracht, einen vollständigen Commodore 64 BASIC V2 Interpreter zu bauen – der komplett im Browser läuft, ohne Installation, ohne Emulator, ohne Kompromisse.


Warum ausgerechnet der C64?

Der Commodore 64 war in den frühen 1980ern nicht einfach nur ein Computer. Er war für Millionen von Kindern und Jugendlichen das erste Gerät, das ihnen sagte: Du kannst hier etwas erschaffen. Kein Vorkenntnisse nötig. Einschalten, und sofort war man im BASIC-Direktmodus. Wer das als Kind erlebt hat, vergisst es nicht.

BASIC V2 war dabei die Sprache der Wahl – simpel genug für Einsteiger, mächtig genug für echte Programme. PRINT, INPUT, FOR/NEXT, GOTO, GOSUB – das waren die Bausteine einer ganzen Generation von Hobbyentwicklern.


Was kann der Interpreter?

Der Browser-Interpreter ist kein Fake. Er versteht echtes Commodore BASIC V2 und führt es aus – Zeile für Zeile, wie auf dem Original.

Unterstützte Statements:

PRINT, INPUT, GET, IF/THEN, GOTO, GOSUB/RETURN, FOR/NEXT, LET, DIM, DATA/READ/RESTORE, DEF FN, ON GOTO/GOSUB, END, STOP, REM

Mathematische Funktionen:

INT, ABS, SGN, SQR, SIN, COS, TAN, ATN, EXP, LOG, RND – plus String-Funktionen wie LEFT$, RIGHT$, MID$, CHR$, ASC, STR$, VAL, LEN

Formatierung:

TAB(), SPC(), Semikolon- und Komma-Trennzeichen im PRINT-Statement – genau wie im Original.

Das bedeutet: Wer früher auf dem C64 programmiert hat, kann seine alten Listings eintippen und sie laufen lassen. Direkt im Browser.


Fünf Demo-Programme zum Sofort-Ausprobieren

Statt einer leeren Kommandozeile gibt es fünf fertige Programme per Klick:

Hello World – Ein klassischer Einstieg mit einer hübschen Rahmengrafik und einer Schleife.

Counter – Zeigt Countdown, Aufzählung und das berühmte ASCII-Dreieck aus Sternchen.

Fibonacci – Die Fibonacci-Reihe in 15 Zeilen BASIC. Elegant und nostalgisch.

Zahlenraten – Ein interaktives Spiel mit INPUT, Zufallszahlen und Schleife. Klassischer Einstieg ins prozedurale Denken.

Sternenhimmel – Arrays, DIM, RND und TAB() zusammen in einem kleinen Visualisierungsexperiment.

Jedes Programm lädt sich mit einem Klick in den Editor. Mit RUN geht's los.


Direkt losprogrammieren

Wer keine Demo braucht, tippt einfach los. Der Interpreter versteht direkten Input genauso wie Programmeingabe mit Zeilennummern:

10 PRINT "WIE HEISST DU?" 
20 INPUT N$
30 PRINT "HALLO, ";N$;"!" 
40 PRINT "DEIN NAME HAT";LEN(N$);"ZEICHEN." 
RUN

Oder direkt ohne Zeilennummern:

FOR I=1 TO 10 : PRINT I*I : NEXT I

LIST zeigt das gespeicherte Programm. NEW löscht es. RUN 50 startet ab Zeile 50. Alles wie früher.


Das Herzstück: Ein echter Parser

Unter der Haube steckt kein simpler Regex-Parser, sondern ein richtiger rekursiver Abstieg-Parser mit korrekter Operatorpriorität:

  • Arithmetik mit +, -, *, /, ^ (Potenz)
  • Logische Operatoren AND, OR, NOT
  • Vergleiche <, >, =, <=, >=, <>
  • Klammerung und verschachtelte Ausdrücke
  • String-Verkettung mit +
  • Benutzerdefinierte Funktionen mit DEF FN

Das Ergebnis: Ausdrücke wie INT(RND(1)*100+1) oder LEFT$(N$,3)+"..." funktionieren einfach – ohne Sonderbehandlung.


Der Look: Authentisch bis ins Detail

Es wäre schade gewesen, den Interpreter einfach in ein weißes Browser-Fenster zu packen. Deshalb steckt er in einem originalgetreuen C64-Gehäuse:

Das charakteristische beige Plastikgehäuse mit Abrundungen und Glanzeffekten, eine blaue Phosphor-Anzeige mit CRT-Scanlines und Flimmer-Animation, der typische Blinkursor, das Commodore-Branding – alles gebaut aus reinem HTML und CSS, ohne ein einziges Bild.

Der Bootscreen begrüßt mit dem Original-Text:

    **** COMMODORE 64 BASIC V2 ****
    
  64K RAM SYSTEM  38911 BASIC BYTES FREE

  READY.

Wer das kennt, wird ein Lächeln nicht unterdrücken können.


Teil einer wachsenden Retro-Toolbox

Der BASIC Interpreter ist Teil einer kleinen Sammlung von Retro-Tools, die ich als einzelne HTML-Dateien baue – keine Abhängigkeiten, keine Server, kein Framework. Einfach herunterladen, im Browser öffnen, loslegen.

Auch das könnte für dich interessant sei:

C64 Sprite Editor – 24×21 Pixel, Hi-Res und Multicolor, 16 C64-Farben, Frameanimation und Export als BASIC DATA, Hex oder Assembler.

Als nächstes geplant:

  • PETSCII Art Editor – Bilder aus C64-Blockzeichen gestalten
  • SID Chip Sequencer – Chiptunes mit den drei SID-Stimmen
  • 6502 Assembler – Maschinencode direkt im Browser

Fazit

Browser-Technologie ist heute mächtig genug, um echte Sprachinterpreter, Pixel-Editoren und Emulatoren zu bauen – mit einem Look, der das Original respektiert. Der C64 BASIC Interpreter ist mein Versuch, diese Nostalgie zugänglich zu machen: für alle, die damals dabei waren, und für alle, die neugierig sind, wie Programmierung anfing.

Kein Emulator. Kein Overhead. Einfach eine HTML-Datei, ein Browser – und READY.

Wie versprochen, der komplette HTML Code oder auch direkt hier abrufbar Commodore 64 BASIC:
 

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>C64 BASIC V2 Interpreter</title>
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{
  background:#0d0d1f;
  background-image:radial-gradient(ellipse at 30% 40%, #1a1a3a 0%, #050510 100%);
  min-height:100vh;
  display:flex;
  flex-direction:column;
  align-items:center;
  justify-content:center;
  padding:20px;
  font-family:'VT323',monospace;
  user-select:none;
}
.c64-case{
  background:linear-gradient(160deg,#e0d5b8 0%,#cfc49d 40%,#b8a87a 100%);
  border-radius:18px 18px 10px 10px;
  padding:30px 30px 20px 30px;
  box-shadow:0 30px 80px rgba(0,0,0,.9),inset 0 1px 0 rgba(255,255,255,.4),inset 0 -3px 6px rgba(0,0,0,.3);
  position:relative;
}
.c64-case::before{
  content:'';
  position:absolute;
  inset:0;
  border-radius:18px 18px 10px 10px;
  background:linear-gradient(180deg,rgba(255,255,255,.08) 0%, transparent 50%);
  pointer-events:none;
}
.screen-bezel{
  background:#111;
  border-radius:10px;
  padding:14px;
  box-shadow:inset 0 6px 20px rgba(0,0,0,.9),inset 0 0 6px rgba(0,0,0,.5);
}
canvas#screen{
  display:block;
  border-radius:4px;
  box-shadow:0 0 50px rgba(80,100,220,.5),0 0 100px rgba(80,100,220,.2);
  cursor:text;
  animation:flicker 12s infinite;
}
@keyframes flicker{
  0%,100%{opacity:1}
  94%{opacity:1}
  94.5%{opacity:.96}
  95%{opacity:1}
  98%{opacity:1}
  98.3%{opacity:.98}
  98.6%{opacity:1}
}
.scanline-overlay{
  position:absolute;
  border-radius:10px;
  top:14px;left:14px;right:14px;bottom:14px;
  background:repeating-linear-gradient(0deg,transparent,transparent 3px,rgba(0,0,0,.06) 3px,rgba(0,0,0,.06) 4px);
  pointer-events:none;
  z-index:10;
}
.c64-branding{
  display:flex;
  justify-content:space-between;
  align-items:center;
  margin-top:14px;
  padding:0 4px;
}
.brand-text{
  color:#7a6a3a;
  font-size:1rem;
  letter-spacing:3px;
  text-transform:uppercase;
}
.brand-logo{
  color:#c8900a;
  font-size:1.5rem;
  letter-spacing:4px;
  text-shadow:0 1px 0 rgba(255,255,255,.2);
}
.c64-vent{
  display:flex;
  gap:3px;
  margin-top:10px;
  justify-content:center;
}
.c64-vent span{
  width:40px;height:3px;
  background:rgba(0,0,0,.2);
  border-radius:2px;
}
.hint-bar{
  margin-top:18px;
  display:flex;
  gap:16px;
  flex-wrap:wrap;
  justify-content:center;
  color:#3a3a5a;
  font-size:.95rem;
  letter-spacing:1px;
}
.hint-bar kbd{
  background:rgba(255,255,255,.08);
  border:1px solid rgba(255,255,255,.15);
  border-radius:3px;
  padding:1px 7px;
  color:#6a6a9a;
  font-family:'VT323',monospace;
  font-size:.95rem;
}
.demo-programs{
  margin-top:14px;
  display:flex;
  gap:8px;
  flex-wrap:wrap;
  justify-content:center;
}
.demo-btn{
  background:rgba(53,64,160,.3);
  border:1px solid rgba(120,120,248,.3);
  color:#7878f8;
  font-family:'VT323',monospace;
  font-size:1rem;
  padding:4px 14px;
  border-radius:4px;
  cursor:pointer;
  letter-spacing:1px;
  transition:background .15s,border-color .15s;
}
.demo-btn:hover{
  background:rgba(53,64,160,.6);
  border-color:rgba(120,120,248,.7);
}
</style>
</head>
<body>

<div class="c64-case">
  <div class="screen-bezel" style="position:relative">
    <canvas id="screen" tabindex="0"></canvas>
    <div class="scanline-overlay"></div>
  </div>
  <div class="c64-branding">
    <span class="brand-text">Commodore 64</span>
    <span class="brand-logo">★ BASIC V2 ★</span>
    <span class="brand-text">64K RAM</span>
  </div>
  <div class="c64-vent">
    <span></span><span></span><span></span><span></span>
    <span></span><span></span><span></span><span></span>
  </div>
</div>

<div class="hint-bar">
  <span><kbd>ENTER</kbd> Ausführen</span>
  <span><kbd>RUN</kbd> Programm starten</span>
  <span><kbd>LIST</kbd> Programm anzeigen</span>
  <span><kbd>NEW</kbd> Löschen</span>
  <span><kbd>CTRL+C</kbd> Stop</span>
</div>

<div class="demo-programs">
  <button class="demo-btn" onclick="loadDemo('hello')">HELLO WORLD</button>
  <button class="demo-btn" onclick="loadDemo('counter')">COUNTER</button>
  <button class="demo-btn" onclick="loadDemo('fibonacci')">FIBONACCI</button>
  <button class="demo-btn" onclick="loadDemo('guessing')">ZAHLENRATEN</button>
  <button class="demo-btn" onclick="loadDemo('starfield')">STERNENHIMMEL</button>
</div>

<script>
'use strict';

// ─────────────────────────────────────────────────────────────
// SCREEN SYSTEM
// ─────────────────────────────────────────────────────────────
const COLS = 40, ROWS = 25;
const C64_BG   = '#3540A0';
const C64_FG   = '#7878F8';
const C64_HI   = '#B0B0FF';
const C64_WHT  = '#FFFFFF';

let cv, ctx, CW, CH = 16;
let sbuf;   // ROWS × COLS → {ch, fg, bg}
let cx = 0, cy = 0;
let curVis = true, blinkTick;

function initScreen() {
  cv = document.getElementById('screen');
  ctx = cv.getContext('2d');
  ctx.font = `bold ${CH}px "Courier New", monospace`;
  CW = Math.round(ctx.measureText('M').width);
  cv.width  = CW * COLS;
  cv.height = CH * ROWS;
  resetBuf();
  blinkTick = setInterval(() => { curVis = !curVis; draw(); }, 400);
  cv.addEventListener('click', () => cv.focus());
}

function resetBuf() {
  sbuf = [];
  for (let r = 0; r < ROWS; r++)
    sbuf.push(Array.from({length:COLS}, () => ({ch:' ', fg:C64_FG, bg:C64_BG})));
  cx = 0; cy = 0;
}

function scroll() {
  sbuf.shift();
  sbuf.push(Array.from({length:COLS}, () => ({ch:' ', fg:C64_FG, bg:C64_BG})));
}

function draw() {
  if (!ctx) return;
  ctx.fillStyle = C64_BG;
  ctx.fillRect(0, 0, cv.width, cv.height);
  ctx.font = `bold ${CH}px "Courier New", monospace`;
  for (let r = 0; r < ROWS; r++) {
    for (let c = 0; c < COLS; c++) {
      const cell = sbuf[r][c];
      if (cell.bg !== C64_BG) {
        ctx.fillStyle = cell.bg;
        ctx.fillRect(c*CW, r*CH, CW, CH);
      }
      if (cell.ch !== ' ') {
        ctx.fillStyle = cell.fg;
        ctx.fillText(cell.ch, c*CW, (r+1)*CH - 3);
      }
    }
  }
  // cursor
  if (curVis) {
    ctx.fillStyle = C64_FG;
    ctx.fillRect(cx*CW, cy*CH, CW, CH);
    const cell = sbuf[cy]?.[cx];
    if (cell && cell.ch !== ' ') {
      ctx.fillStyle = C64_BG;
      ctx.fillText(cell.ch, cx*CW, (cy+1)*CH - 3);
    }
  }
}

function putCh(ch, fg, bg) {
  fg = fg || C64_FG; bg = bg || C64_BG;
  if (cx >= COLS) { cx = 0; cy++; if (cy >= ROWS) { scroll(); cy = ROWS-1; } }
  sbuf[cy][cx] = {ch, fg, bg};
  cx++;
}

function sWrite(s, fg, bg) {
  for (const ch of s) {
    if (ch === '\n') {
      cx = 0; cy++;
      if (cy >= ROWS) { scroll(); cy = ROWS-1; }
    } else {
      putCh(ch.toUpperCase(), fg, bg);
    }
  }
}

function sWriteln(s, fg) { sWrite((s||'') + '\n', fg); }

function printReady() {
  sWriteln('');
  sWriteln('READY.', C64_HI);
  draw();
}

function screenCls() { resetBuf(); }

// ─────────────────────────────────────────────────────────────
// INPUT STATE
// ─────────────────────────────────────────────────────────────
let inputLine    = '';   // what user is typing
let inputResolve = null; // promise resolver for INPUT stmt
let getResolve   = null; // promise resolver for GET stmt
let progRunning  = false;
let interp;

document.addEventListener('keydown', onKey);

function onKey(e) {
  if (e.key === 'Tab') { e.preventDefault(); return; }

  // Ctrl+C / Escape → stop
  if ((e.ctrlKey && e.key === 'c') || e.key === 'Escape') {
    if (interp && progRunning) interp.stop();
    return;
  }

  // GET mode: single key
  if (getResolve) {
    if (e.key.length === 1) {
      e.preventDefault();
      const r = getResolve; getResolve = null;
      r(e.key.toUpperCase());
    }
    return;
  }

  e.preventDefault();

  if (e.key === 'Enter') {
    sWriteln('');
    const line = inputLine; inputLine = '';
    if (inputResolve) {
      const r = inputResolve; inputResolve = null;
      r(line);
    } else if (!progRunning) {
      processInput(line);
    }
    draw();
    return;
  }

  if (e.key === 'Backspace') {
    if (inputLine.length > 0) {
      inputLine = inputLine.slice(0, -1);
      cx--; if (cx < 0) { cx = COLS-1; cy = Math.max(0, cy-1); }
      sbuf[cy][cx] = {ch:' ', fg:C64_FG, bg:C64_BG};
      draw();
    }
    return;
  }

  if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
    const ch = e.key.toUpperCase();
    inputLine += ch;
    putCh(ch);
    draw();
  }
}

// ─────────────────────────────────────────────────────────────
// DIRECT MODE HANDLER
// ─────────────────────────────────────────────────────────────
async function processInput(raw) {
  const line = raw.trim();
  if (!line) { draw(); return; }
  const upper = line.toUpperCase();

  const m = upper.match(/^(\d+)\s*(.*)/);
  if (m) {
    const ln = parseInt(m[1]);
    const rest = m[2].trim();
    if (!rest) interp.program.delete(ln);
    else       interp.program.set(ln, rest);
    interp.rebuildLines();
    draw();
    return;
  }

  progRunning = true;
  try {
    await interp.execDirect(upper);
  } catch(err) {
    sWriteln('?' + err.message.toUpperCase().replace(/ ERROR$/,'') + ' ERROR');
    printReady();
  }
  progRunning = false;
  draw();
}

// ─────────────────────────────────────────────────────────────
// TOKENIZER
// ─────────────────────────────────────────────────────────────
class Tok {
  constructor(src) { this.src = src; this.pos = 0; }
  get eof() { return this.pos >= this.src.length; }
  ws()  { while (this.pos < this.src.length && this.src[this.pos] === ' ') this.pos++; }

  next() {
    this.ws();
    if (this.pos >= this.src.length) return {t:'EOF'};
    const ch = this.src[this.pos];

    if ((ch>='0'&&ch<='9')||(ch==='.'&&this.src[this.pos+1]>='0'&&this.src[this.pos+1]<='9')) {
      let s='';
      while (this.pos < this.src.length) {
        const c = this.src[this.pos];
        if ((c>='0'&&c<='9')||c==='.') { s+=c; this.pos++; }
        else if ((c==='E')&&s) {
          s+=c; this.pos++;
          if (this.pos<this.src.length && (this.src[this.pos]==='+'||this.src[this.pos]==='-'))
            s+=this.src[this.pos++];
        } else break;
      }
      return {t:'N', v:parseFloat(s)};
    }

    if (ch==='"') {
      this.pos++;
      let s='';
      while (this.pos<this.src.length && this.src[this.pos]!=='"') s+=this.src[this.pos++];
      if (this.src[this.pos]==='"') this.pos++;
      return {t:'S', v:s};
    }

    if (ch>='A'&&ch<='Z') {
      let s='';
      while (this.pos<this.src.length) {
        const c = this.src[this.pos];
        if ((c>='A'&&c<='Z')||(c>='0'&&c<='9')) { s+=c; this.pos++; } else break;
      }
      if (this.pos<this.src.length&&(this.src[this.pos]==='$'||this.src[this.pos]==='%'))
        s+=this.src[this.pos++];
      return {t:'W', v:s};
    }

    if (ch==='<') {
      this.pos++;
      if (this.src[this.pos]==='=') { this.pos++; return {t:'O',v:'<='}; }
      if (this.src[this.pos]==='>') { this.pos++; return {t:'O',v:'<>'}; }
      return {t:'O',v:'<'};
    }
    if (ch==='>') {
      this.pos++;
      if (this.src[this.pos]==='=') { this.pos++; return {t:'O',v:'>='}; }
      return {t:'O',v:'>'};
    }
    this.pos++;
    return {t:'O',v:ch};
  }

  peek()        { const p=this.pos; const t=this.next(); this.pos=p; return t; }
  peekW(w)      { const t=this.peek(); return t.t==='W'&&t.v===w; }
  eatW(w)       { if (this.peekW(w)) { this.next(); return true; } return false; }
  expect(t,v)   { const tk=this.next(); if(v!==undefined&&tk.v!==v)throw new Error(`SYNTAX`); return tk; }
  expectW(w)    { const tk=this.next(); if(tk.t!=='W'||tk.v!==w) throw new Error(`SYNTAX`); return tk; }
  expectWV()    { const tk=this.next(); if(tk.t!=='W') throw new Error(`SYNTAX`); return tk.v; }
  rest()        { return this.src.slice(this.pos); }
}

// ─────────────────────────────────────────────────────────────
// EXPRESSION EVALUATOR
// ─────────────────────────────────────────────────────────────
function eOR(tok,I)  { let l=eAND(tok,I); while(tok.eatW('OR'))  { const r=eAND(tok,I); l=(bv(l)||bv(r))?-1:0; } return l; }
function eAND(tok,I) { let l=eNOT(tok,I); while(tok.eatW('AND')) { const r=eNOT(tok,I); l=(bv(l)&&bv(r))?-1:0; } return l; }
function eNOT(tok,I) { if(tok.eatW('NOT')) { return bv(eNOT(tok,I))?0:-1; } return eCMP(tok,I); }
function eCMP(tok,I) {
  let l=eADD(tok,I);
  for(;;) {
    const p=tok.peek();
    if(p.t!=='O'||!['<','>','=','<=','>=','<>'].includes(p.v)) break;
    tok.next();
    const r=eADD(tok,I);
    l=cmp(l,p.v,r);
  }
  return l;
}
function cmp(a,op,b) {
  let res;
  const ne=(x,y)=>typeof x==='number'&&typeof y==='number'&&Math.abs(x-y)<1e-9;
  switch(op){
    case '<': res=a<b;break; case '>': res=a>b;break;
    case '=': res=(a===b||ne(a,b));break;
    case '<=':res=a<=b||ne(a,b);break; case '>=':res=a>=b||ne(a,b);break;
    case '<>':res=!(a===b||ne(a,b));break; default: res=false;
  }
  return res?-1:0;
}
function eADD(tok,I) {
  let l=eMUL(tok,I);
  for(;;) {
    const p=tok.peek();
    if(p.t!=='O'||!(p.v==='+'||p.v==='-')) break;
    tok.next();
    const r=eMUL(tok,I);
    if(p.v==='+') l=(typeof l==='string'||typeof r==='string')?String(l)+String(r):l+r;
    else l=num(l)-num(r);
  }
  return l;
}
function eMUL(tok,I) {
  let l=ePOW(tok,I);
  for(;;) {
    const p=tok.peek();
    if(p.t!=='O'||!(p.v==='*'||p.v==='/')) break;
    tok.next();
    const r=ePOW(tok,I);
    if(p.v==='*') l=num(l)*num(r);
    else { const d=num(r); if(d===0) throw new Error('DIVISION BY ZERO'); l=num(l)/d; }
  }
  return l;
}
function ePOW(tok,I) {
  let l=eUNARY(tok,I);
  if(tok.peek().t==='O'&&tok.peek().v==='^') { tok.next(); l=Math.pow(num(l),num(ePOW(tok,I))); }
  return l;
}
function eUNARY(tok,I) {
  const p=tok.peek();
  if(p.t==='O'&&p.v==='-') { tok.next(); return -num(eUNARY(tok,I)); }
  if(p.t==='O'&&p.v==='+') { tok.next(); return  num(eUNARY(tok,I)); }
  return ePRI(tok,I);
}

function fnArg(tok,I)  { tok.expect('O','('); const v=eOR(tok,I); tok.expect('O',')'); return v; }

function ePRI(tok,I) {
  const t=tok.next();
  if(t.t==='N') return t.v;
  if(t.t==='S') return t.v;
  if(t.t==='O'&&t.v==='(') { const v=eOR(tok,I); tok.expect('O',')'); return v; }
  if(t.t==='W') {
    const n=t.v;
    switch(n) {
      case 'ABS':    return Math.abs(num(fnArg(tok,I)));
      case 'INT':    return Math.trunc(num(fnArg(tok,I)));
      case 'SGN':  { const v=num(fnArg(tok,I)); return v>0?1:v<0?-1:0; }
      case 'SQR':    return Math.sqrt(Math.max(0,num(fnArg(tok,I))));
      case 'SIN':    return Math.sin(num(fnArg(tok,I)));
      case 'COS':    return Math.cos(num(fnArg(tok,I)));
      case 'TAN':    return Math.tan(num(fnArg(tok,I)));
      case 'ATN':    return Math.atan(num(fnArg(tok,I)));
      case 'EXP':    return Math.exp(num(fnArg(tok,I)));
      case 'LOG':  { const v=num(fnArg(tok,I)); if(v<=0) throw new Error('ILLEGAL QUANTITY'); return Math.log(v); }
      case 'RND':  { fnArg(tok,I); return Math.random(); }
      case 'FRE':    fnArg(tok,I); return 38911;
      case 'POS':    fnArg(tok,I); return cx;
      case 'PEEK':   return 0;
      case 'LEN':    return String(fnArg(tok,I)).length;
      case 'ASC':  { const s=String(fnArg(tok,I)); if(!s) throw new Error('ILLEGAL QUANTITY'); return s.charCodeAt(0); }
      case 'CHR$':   return String.fromCharCode(num(fnArg(tok,I))&255);
      case 'STR$':  { const v=num(fnArg(tok,I)); return (v>=0?' ':'')+fmtNum(v); }
      case 'VAL':  { const s=String(fnArg(tok,I)).trim(); const n=parseFloat(s); return isNaN(n)?0:n; }
      case 'LEFT$': { tok.expect('O','('); const s=String(eOR(tok,I)); tok.expect('O',','); const n=num(eOR(tok,I)); tok.expect('O',')'); return s.slice(0,Math.max(0,n)); }
      case 'RIGHT$':{ tok.expect('O','('); const s=String(eOR(tok,I)); tok.expect('O',','); const n=num(eOR(tok,I)); tok.expect('O',')'); return s.slice(Math.max(0,s.length-n)); }
      case 'MID$':  {
        tok.expect('O','(');
        const s=String(eOR(tok,I));
        tok.expect('O',',');
        const st=Math.max(1,num(eOR(tok,I)))-1;
        let ln=s.length;
        if(tok.peek().t==='O'&&tok.peek().v===',') { tok.next(); ln=num(eOR(tok,I)); }
        tok.expect('O',')');
        return s.slice(st,st+Math.max(0,ln));
      }
      case 'STRING$':{
        tok.expect('O','(');
        const n=num(eOR(tok,I));
        tok.expect('O',',');
        const a=eOR(tok,I);
        tok.expect('O',')');
        const ch=typeof a==='string'?a[0]||' ':String.fromCharCode(num(a)&255);
        return ch.repeat(Math.max(0,n));
      }
      case 'SPACE$':{ const n=num(fnArg(tok,I)); return ' '.repeat(Math.max(0,n)); }
      case 'TAB':   return {__tab:num(fnArg(tok,I))};
      case 'SPC':   return ' '.repeat(Math.max(0,num(fnArg(tok,I))));
    }

    // FN user function
    if(n.startsWith('FN')) {
      const fn=I.fnDefs[n.slice(2)];
      if(!fn) throw new Error(`UNDEF\'D FUNCTION`);
      const arg=fnArg(tok,I);
      const old=I.vars[fn.param];
      I.vars[fn.param]=arg;
      const t2=new Tok(fn.expr);
      const res=eOR(t2,I);
      I.vars[fn.param]=old;
      return res;
    }

    // Array access
    if(tok.peek().t==='O'&&tok.peek().v==='(') {
      tok.next();
      const idx=[num(eOR(tok,I))];
      while(tok.peek().t==='O'&&tok.peek().v===',') { tok.next(); idx.push(num(eOR(tok,I))); }
      tok.expect('O',')');
      return I.arrGet(n,idx);
    }

    return I.varGet(n);
  }
  throw new Error('SYNTAX');
}

function num(v) {
  if(v===null||v===undefined) return 0;
  if(typeof v==='number') return v;
  if(typeof v==='string') { const n=parseFloat(v); return isNaN(n)?0:n; }
  return 0;
}
function bv(v) { return typeof v==='number'?v!==0:v!==''; }
function fmtNum(v) {
  if(Number.isInteger(v)&&Math.abs(v)<1e15) return v.toString();
  let s=v.toPrecision(9);
  s=s.replace(/\.?0+(e|$)/,(m,e)=>e||'');
  return s;
}
function valStr(v) {
  if(typeof v==='string') return v;
  if(typeof v==='number') return (v>=0?' ':'')+fmtNum(v);
  return String(v);
}

// ─────────────────────────────────────────────────────────────
// STATEMENT SPLITTER
// ─────────────────────────────────────────────────────────────
function splitStmts(src) {
  if (/^REM\b/.test(src.trimStart())) return [src.trim()];
  const out=[];
  let cur='', inStr=false;
  for (let i=0; i<src.length; i++) {
    const c=src[i];
    if(c==='"'&&!inStr) { inStr=true; cur+=c; }
    else if(c==='"'&&inStr) { inStr=false; cur+=c; }
    else if(inStr) { cur+=c; }
    else if(c===':') {
      if(cur.trim()) out.push(cur.trim());
      const rest=src.slice(i+1).trimStart();
      if(/^REM\b/.test(rest)) { out.push(rest); return out; }
      cur='';
    } else { cur+=c; }
  }
  if(cur.trim()) out.push(cur.trim());
  return out;
}

// ─────────────────────────────────────────────────────────────
// INTERPRETER
// ─────────────────────────────────────────────────────────────
class Interpreter {
  constructor() {
    this.program = new Map();
    this.lines   = [];
    this.vars    = {};
    this.arrs    = {};
    this.fnDefs  = {};
    this.forStk  = [];
    this.subStk  = [];
    this.data    = [];
    this.dataPtr = 0;
    this.pc=0; this.si=0; this.jumped=false;
    this.running=false;
  }

  rebuildLines() {
    this.lines=[...this.program.keys()].sort((a,b)=>a-b);
    this.collectData();
  }

  collectData() {
    this.data=[];
    for(const ln of this.lines) {
      for(const s of splitStmts(this.program.get(ln))) {
        if(s.startsWith('DATA')) {
          const tok=new Tok(s.slice(4).trim());
          while(!tok.eof) {
            const p=tok.peek();
            if(p.t==='S') { tok.next(); this.data.push(p.v); }
            else if(p.t==='N') { tok.next(); this.data.push(p.v); }
            else if(p.t==='O'&&p.v==='-') { tok.next(); const n=tok.next(); this.data.push(-num(n.v)); }
            else if(p.t==='W') { tok.next(); this.data.push(p.v); }
            else if(p.t==='O'&&p.v===',') { tok.next(); }
            else tok.next();
          }
        }
      }
    }
  }

  varGet(name) {
    const v=this.vars[name];
    return v===undefined?(name.endsWith('$')?'':0):v;
  }
  varSet(name,val) {
    this.vars[name]=name.endsWith('$')?String(val):num(val);
  }
  arrGet(name,idx) {
    const a=this.arrs[name]; if(!a) return name.endsWith('$')?'':0;
    return a.d[this._flatIdx(a,idx)]??(name.endsWith('$')?'':0);
  }
  arrSet(name,idx,val) {
    if(!this.arrs[name]) this.dimArr(name,idx.map(()=>11));
    const a=this.arrs[name];
    a.d[this._flatIdx(a,idx)]=name.endsWith('$')?String(val):num(val);
  }
  dimArr(name,dims) {
    const sz=dims.reduce((a,b)=>a*b,1);
    this.arrs[name]={dims,d:new Array(sz).fill(name.endsWith('$')?'':0)};
  }
  _flatIdx(a,idx) {
    let i=0;
    for(let d=0;d<idx.length;d++) {
      const stride=a.dims.slice(d+1).reduce((x,y)=>x*y,1);
      i+=idx[d]*stride;
    }
    return i;
  }

  findLine(ln) {
    const i=this.lines.indexOf(ln);
    if(i<0) throw new Error(`UNDEF\'D STATEMENT`);
    return i;
  }

  stop() {
    this.running=false;
    sWriteln('');
    sWriteln('BREAK', C64_HI);
    printReady();
    progRunning=false;
    draw();
  }

  clr() {
    this.vars={}; this.arrs={}; this.fnDefs={};
    this.forStk=[]; this.subStk=[]; this.dataPtr=0;
  }

  async run(startPc) {
    this.running=true; progRunning=true;
    this.pc=startPc||0; this.si=0; this.jumped=false;
    this.clr();
    let ticks=0;
    try {
      while(this.running && this.pc<this.lines.length) {
        ticks++;
        if(ticks%200===0) { await sleep(0); draw(); }

        const src=this.program.get(this.lines[this.pc]);
        const stmts=splitStmts(src);

        if(this.si>=stmts.length) { this.pc++; this.si=0; continue; }

        while(this.si<stmts.length && this.running) {
          this.jumped=false;
          await this.execS(stmts[this.si], stmts, this.si);
          if(this.jumped) break;
          this.si++;
        }
        if(!this.jumped) { this.pc++; this.si=0; }
      }
    } catch(e) {
      const ln=this.lines[this.pc];
      sWriteln('');
      sWriteln(`?${e.message.toUpperCase().replace(/ ERROR$/,'')} ERROR IN ${ln}`);
    }
    if(this.running) { sWriteln(''); printReady(); }
    this.running=false; progRunning=false; draw();
  }

  async execDirect(src) {
    // System commands
    const trimmed=src.trim();

    if(trimmed==='LIST'||/^LIST\s/.test(trimmed)) {
      const m=trimmed.match(/^LIST\s*(\d*)\s*(-\s*(\d*))?/);
      const from=m&&m[1]?parseInt(m[1]):undefined;
      const to=m&&m[3]?parseInt(m[3]):from;
      this.list(from,to);
      printReady();
      return;
    }
    if(trimmed==='NEW') { this.program.clear(); this.rebuildLines(); this.clr(); printReady(); return; }
    if(trimmed==='CLR') { this.clr(); printReady(); return; }
    if(trimmed==='RUN') { await this.run(0); return; }
    const runM=trimmed.match(/^RUN\s+(\d+)/);
    if(runM) { await this.run(this.findLine(parseInt(runM[1]))); return; }
    if(trimmed==='CONT') { printReady(); return; }

    // Execute as inline BASIC
    this.running=true; progRunning=true;
    try {
      const stmts=splitStmts(trimmed);
      for(let i=0;i<stmts.length;i++) {
        if(!this.running) break;
        this.jumped=false;
        await this.execS(stmts[i], stmts, i);
        if(this.jumped) break;
      }
    } catch(e) {
      sWriteln('?'+e.message.toUpperCase().replace(/ ERROR$/,'')+' ERROR');
    }
    this.running=false; progRunning=false;
    printReady();
  }

  list(from, to) {
    if(!this.lines.length) return;
    for(const ln of this.lines) {
      if(from!==undefined&&ln<from) continue;
      if(to!==undefined&&ln>to) continue;
      sWriteln(`${ln} ${this.program.get(ln)}`);
    }
  }

  async execS(stmt, stmts, si) {
    const tok=new Tok(stmt);
    if(tok.eof) return;

    // Detect implicit LET: WORD [( ] =
    const p0=tok.pos;
    const t0=tok.peek();
    let isLet=false;
    if(t0.t==='W'&&t0.v!=='NOT') {
      tok.next();
      if(tok.peek().t==='O'&&tok.peek().v==='(') {
        tok.next(); let d=1;
        while(!tok.eof&&d>0) { const tt=tok.next(); if(tt.v==='(')d++; if(tt.v===')')d--; }
      }
      if(tok.peek().t==='O'&&tok.peek().v==='=') isLet=true;
      tok.pos=p0;
    }

    if(isLet) { await this.sLET(tok); return; }

    const kw=tok.next();
    if(kw.t!=='W') return;

    switch(kw.v) {
      case 'LET':    await this.sLET(tok); break;
      case 'PRINT':
      case '?':      await this.sPRINT(tok); break;
      case 'INPUT':  await this.sINPUT(tok); break;
      case 'GET':    await this.sGET(tok); break;
      case 'IF':     await this.sIF(tok,stmts,si); break;
      case 'GOTO':   this.sGOTO(tok); break;
      case 'GO': { tok.expectW('TO'); this.sGOTO(tok); break; }
      case 'GOSUB':  this.sGOSUB(tok); break;
      case 'RETURN': this.sRETURN(); break;
      case 'FOR':    await this.sFOR(tok); break;
      case 'NEXT':   this.sNEXT(tok); break;
      case 'END':    this.running=false; break;
      case 'STOP':   this.stop(); break;
      case 'REM':    break;
      case 'DATA':   break;
      case 'READ':   this.sREAD(tok); break;
      case 'RESTORE':
        if(!tok.eof&&tok.peek().t==='N') {
          const ln=num(tok.next().v);
          // find data ptr for that line
          this.dataPtr=0;
          for(const l of this.lines) {
            if(l>=ln) break;
            for(const s of splitStmts(this.program.get(l)))
              if(s.startsWith('DATA'))
                this.dataPtr+=this._countData(s.slice(4).trim());
          }
        } else this.dataPtr=0;
        break;
      case 'DIM':    this.sDIM(tok); break;
      case 'DEF':    this.sDEF(tok); break;
      case 'ON':     await this.sON(tok); break;
      case 'POKE':   { eOR(tok,this); tok.expect('O',','); eOR(tok,this); break; }
      case 'SYS':    eOR(tok,this); break;
      case 'PRINT#': case 'INPUT#': case 'OPEN': case 'CLOSE':
      case 'LOAD': case 'SAVE': case 'VERIFY':
        while(!tok.eof) tok.next(); break;
      case 'WAIT': {
        eOR(tok,this);
        if(tok.peek().v===',') { tok.next(); eOR(tok,this); }
        break;
      }
      case 'LIST': this.list(); break;
      case 'NEW':  this.program.clear(); this.rebuildLines(); this.clr(); break;
      case 'CLR':  this.clr(); break;
      // PRINT with line clear (C= specific)
      default: break;
    }
  }

  _countData(src) {
    let n=0; const tok=new Tok(src);
    while(!tok.eof) {
      const p=tok.peek();
      if(p.t==='O'&&p.v===',') { tok.next(); continue; }
      if(p.t==='EOF') break;
      tok.next(); n++;
    }
    return n;
  }

  async sLET(tok) {
    const name=tok.expectWV();
    if(tok.peek().t==='O'&&tok.peek().v==='(') {
      tok.next();
      const idx=[num(eOR(tok,this))];
      while(tok.peek().v===',') { tok.next(); idx.push(num(eOR(tok,this))); }
      tok.expect('O',')');
      tok.expect('O','=');
      this.arrSet(name,idx,eOR(tok,this));
    } else {
      tok.expect('O','=');
      this.varSet(name,eOR(tok,this));
    }
  }

  async sPRINT(tok) {
    let nl=true;
    while(!tok.eof) {
      const p=tok.peek();
      if(p.t==='O'&&p.v===':') break;
      if(p.t==='O'&&p.v===';') { tok.next(); nl=false; continue; }
      if(p.t==='O'&&p.v===',') {
        tok.next(); nl=false;
        const next14=(Math.floor(cx/14)+1)*14;
        while(cx<Math.min(next14,COLS)) putCh(' ');
        continue;
      }
      nl=true;
      const val=eOR(tok,this);
      if(val&&typeof val==='object'&&val.__tab!==undefined) {
        const target=Math.max(0,val.__tab-1);
        while(cx<target&&cx<COLS) putCh(' ');
      } else {
        sWrite(valStr(val));
      }
    }
    if(nl) sWrite('\n');
  }

  async sINPUT(tok) {
    // Optional prompt
    let prompt='? ';
    if(tok.peek().t==='S') {
      prompt=tok.next().v;
      if(tok.peek().t==='O'&&tok.peek().v===';') tok.next();
      else if(tok.peek().t==='O'&&tok.peek().v===',') tok.next();
    } else {
      sWrite('? ');
    }
    if(prompt!=='? ') sWrite(prompt);

    const line = await this.waitInput();
    const parts=line.split(',');
    let pi=0;
    while(!tok.eof&&pi<parts.length) {
      const name=tok.expectWV();
      const s=(parts[pi++]||'').trim();
      if(name.endsWith('$')) this.varSet(name,s);
      else { const n=parseFloat(s); this.varSet(name,isNaN(n)?0:n); }
      if(tok.peek().v===',') tok.next();
    }
  }

  async sGET(tok) {
    const name=tok.expectWV();
    const ch = await this.waitGet();
    this.varSet(name,ch);
  }

  sGOTO(tok) {
    const ln=num(eOR(tok,this));
    this.pc=this.findLine(ln);
    this.si=0; this.jumped=true;
  }

  sGOSUB(tok) {
    const ln=num(eOR(tok,this));
    this.subStk.push({pc:this.pc,si:this.si+1});
    this.pc=this.findLine(ln);
    this.si=0; this.jumped=true;
  }

  sRETURN() {
    if(!this.subStk.length) throw new Error('RETURN WITHOUT GOSUB');
    const r=this.subStk.pop();
    this.pc=r.pc; this.si=r.si; this.jumped=true;
  }

  async sIF(tok, stmts, si) {
    const cond=eOR(tok,this);
    tok.expectW('THEN');
    if(!bv(cond)) {
      // skip rest of line
      this.si=99999;
      return;
    }
    // Check for line number
    if(tok.peek().t==='N') {
      const ln=num(tok.next().v);
      this.pc=this.findLine(ln);
      this.si=0; this.jumped=true;
    } else if(!tok.eof) {
      // Execute rest as statement(s)
      const rest=tok.rest().trim();
      const sub=splitStmts(rest);
      for(let i=0;i<sub.length&&this.running;i++) {
        this.jumped=false;
        await this.execS(sub[i],sub,i);
        if(this.jumped) return;
      }
    }
  }

  async sFOR(tok) {
    const vname=tok.expectWV();
    tok.expect('O','=');
    const from=num(eOR(tok,this));
    tok.expectW('TO');
    const to=num(eOR(tok,this));
    let step=1;
    if(tok.eatW('STEP')) step=num(eOR(tok,this));
    this.varSet(vname,from);
    this.forStk.push({vname,to,step,retPc:this.pc,retSi:this.si+1});
  }

  sNEXT(tok) {
    let vname=null;
    if(!tok.eof&&tok.peek().t==='W') vname=tok.next().v;

    let fi=-1;
    for(let i=this.forStk.length-1;i>=0;i--) {
      if(!vname||this.forStk[i].vname===vname) { fi=i; break; }
    }
    if(fi<0) throw new Error('NEXT WITHOUT FOR');
    const fe=this.forStk[fi];
    const newVal=num(this.varGet(fe.vname))+fe.step;
    this.varSet(fe.vname,newVal);

    const done=fe.step>0?newVal>fe.to:newVal<fe.to;
    if(done) {
      this.forStk.splice(fi,1);
    } else {
      this.pc=fe.retPc; this.si=fe.retSi; this.jumped=true;
    }
  }

  sREAD(tok) {
    do {
      const name=tok.expectWV();
      if(this.dataPtr>=this.data.length) throw new Error('OUT OF DATA');
      const v=this.data[this.dataPtr++];
      this.varSet(name,v);
    } while(tok.peek().v===','&&tok.next());
  }

  sDIM(tok) {
    do {
      const name=tok.expectWV();
      tok.expect('O','(');
      const dims=[num(eOR(tok,this))+1];
      while(tok.peek().v===',') { tok.next(); dims.push(num(eOR(tok,this))+1); }
      tok.expect('O',')');
      this.dimArr(name,dims);
    } while(tok.peek().v===','&&tok.next());
  }

  sDEF(tok) {
    tok.expectW('FN');
    const name=tok.expectWV();
    tok.expect('O','(');
    const param=tok.expectWV();
    tok.expect('O',')');
    tok.expect('O','=');
    const expr=tok.rest();
    this.fnDefs[name]={param,expr};
  }

  async sON(tok) {
    const v=Math.round(num(eOR(tok,this)));
    const cmd=tok.next().v; // GOTO or GOSUB
    const targets=[];
    do {
      targets.push(num(eOR(tok,this)));
      if(tok.peek().v===',') tok.next(); else break;
    } while(!tok.eof);
    if(v<1||v>targets.length) return;
    const ln=targets[v-1];
    if(cmd==='GOTO') this.sGOTO({peek:()=>({t:'N',v:ln}),next:()=>({t:'N',v:ln}),eof:false});
    else if(cmd==='GOSUB') {
      this.subStk.push({pc:this.pc,si:this.si+1});
      this.pc=this.findLine(ln); this.si=0; this.jumped=true;
    }
  }

  waitInput() {
    return new Promise(r => { inputResolve=r; draw(); });
  }
  waitGet() {
    return new Promise(r => { getResolve=r; draw(); });
  }
}

// ─────────────────────────────────────────────────────────────
// DEMO PROGRAMS
// ─────────────────────────────────────────────────────────────
const DEMOS = {
  hello: {
    title:'HELLO WORLD',
    code:[
      '10 PRINT "╔══════════════════════════════╗"',
      '20 PRINT "║   HELLO, WORLD! GREETINGS!   ║"',
      '30 PRINT "╚══════════════════════════════╝"',
      '40 PRINT ""',
      '50 FOR I=1 TO 5',
      '60 PRINT "  *** HELLO WORLD NR.";I;"***"',
      '70 NEXT I',
      '80 PRINT ""',
      '90 PRINT "PROGRAM ENDED. GOODBYE!"',
    ]
  },
  counter: {
    title:'COUNTER',
    code:[
      '10 PRINT "COUNTDOWN:"',
      '20 FOR I=10 TO 0 STEP -1',
      '30 PRINT I',
      '40 NEXT I',
      '50 PRINT ""',
      '60 PRINT "AUFSTIEG:"',
      '70 FOR I=1 TO 20',
      '80 FOR J=1 TO I',
      '90 PRINT "*";',
      '100 NEXT J',
      '110 PRINT ""',
      '120 NEXT I',
    ]
  },
  fibonacci: {
    title:'FIBONACCI',
    code:[
      '10 PRINT "FIBONACCI-REIHE:"',
      '20 PRINT ""',
      '30 A=0 : B=1',
      '40 FOR I=1 TO 15',
      '50 PRINT A',
      '60 C=A+B : A=B : B=C',
      '70 NEXT I',
    ]
  },
  guessing: {
    title:'ZAHLENRATEN',
    code:[
      '10 PRINT "ZAHLENRATEN (1-100)"',
      '20 PRINT ""',
      '30 Z=INT(RND(1)*100)+1',
      '40 V=0',
      '50 V=V+1',
      '60 PRINT "VERSUCH";V;": DEINE ZAHL? ";',
      '70 INPUT G',
      '80 IF G<Z THEN PRINT "  ZU KLEIN!" : GOTO 50',
      '90 IF G>Z THEN PRINT "  ZU GROSS!" : GOTO 50',
      '100 PRINT ""',
      '110 PRINT "RICHTIG! NACH";V;"VERSUCHEN!"',
    ]
  },
  starfield: {
    title:'STERNENHIMMEL',
    code:[
      '10 DIM X(20),Y(20)',
      '20 FOR I=1 TO 20',
      '30 X(I)=INT(RND(1)*38)+1',
      '40 Y(I)=INT(RND(1)*20)+1',
      '50 NEXT I',
      '60 FOR F=1 TO 5',
      '70 PRINT "  === STERNENHIMMEL ==="',
      '80 FOR I=1 TO 20',
      '90 PRINT TAB(X(I));',
      '100 IF INT(RND(1)*3)=0 THEN PRINT "*" ELSE PRINT "."',
      '110 NEXT I',
      '120 NEXT F',
    ]
  }
};

function loadDemo(name) {
  const d=DEMOS[name];
  if(!d) return;
  interp.program.clear();
  for(const line of d.code) {
    const m=line.match(/^(\d+)\s*(.*)/);
    if(m) interp.program.set(parseInt(m[1]),m[2]);
  }
  interp.rebuildLines();
  screenCls();
  sWriteln(' *** '+d.title+' ***', C64_HI);
  sWriteln('');
  interp.list();
  sWriteln('');
  sWriteln('READY.', C64_HI);
  draw();
}

function sleep(ms) { return new Promise(r=>setTimeout(r,ms)); }

// ─────────────────────────────────────────────────────────────
// BOOT
// ─────────────────────────────────────────────────────────────
window.addEventListener('load', () => {
  initScreen();
  interp=new Interpreter();

  // Boot sequence
  sWriteln('');
  sWriteln('    **** COMMODORE 64 BASIC V2 ****', C64_WHT);
  sWriteln('');
  sWriteln(' 64K RAM SYSTEM  38911 BASIC BYTES FREE', C64_HI);
  sWriteln('');
  sWriteln('READY.', C64_HI);
  draw();
});
</script>
</body>
</html>