Three Languages, One Weekend (Sort Of)
Building BASIC, Forth, and Scheme interpreters in Rust, running them in browser workers, and what the contrast between them revealed.
On this page
The Forth interpreter has 396 tests for roughly 150 words. That ratio isn’t caution; it’s what happens when you build a stack language and discover that a single push/pop mismatch corrupts everything downstream with no error message. I wrote those tests one at a time, each one after a bug that had already cost me an evening.
I built BASIC, Forth, and Scheme interpreters in Rust, compiled them to WebAssembly, and ran the first two inside dedicated Web Workers for emulator.ca’s modem simulator. It took considerably more than a weekend. The loop was weekend-shaped though: implement a subsystem, expose a thin WASM surface, route modem bytes through it, repeat.
Three languages because they disagree with each other. BASIC, Forth, and Scheme take opposite positions on syntax, memory, and control flow, and building all three against the same host turns those differences into things you can measure instead of things you can argue about.
The Shared Loop
The host side standardizes on one pattern: WorkerBasedInterpreterBackend spins up a Web Worker, loads the WASM module, then pipes characters in and output out. That pattern is the invariant; everything else hangs off it.
// web/src/backend/worker-based-interpreter-backend.ts
export abstract class WorkerBasedInterpreterBackend extends BackendInterface {
protected abstract createWorker(): Worker;
async initWorker(): Promise<void> {
if (this.workerReady) return;
this.worker = this.createWorker();
this.worker.addEventListener('message', (event) => {
this.handleWorkerMessage(event.data);
});
}
}
The worker creation pattern is strict because Vite has to see the new URL() inside the new Worker() call to bundle the worker at all — a build-time constraint that ripples into every backend. Once you accept it, a language backend is a worker path plus some signal handling.
BASIC lives at 555-0300, Forth at 555-0400. The host code for both is nearly identical, which is the point. The interesting differences are inside the interpreters.
BASIC: A Line-Numbered Control Graph
BASIC looks linear and isn’t. Line numbers turn the program into a random-access structure, so GOTO 500 is a dictionary lookup rather than a jump. Parsing, storage, and execution all treat the program as addressable by line number, because that’s what the language is.
Two Tokenizers, One Truth
The interpreter uses a hand-rolled tokenizer with years of edge-case fixes in it. I also built a Pest-based replacement and keep the two in lockstep with cross-checking tests. The manual parser has the scar tissue; the Pest parser is the second opinion I need before I trust a migration.
// languages/basic/src/pest_parser.rs
// This Pest-based parser is not currently used. The interpreter uses
// a manual tokenizer in parser.rs. Tests compare both outputs.
“Don’t regress the edge cases” is why the migration has stayed staged for months, and I’m fine with that.
TI and TI$ Are Real, Not Placeholders
Plenty of BASIC interpreters fake the system variables. This one doesn’t. TI tracks elapsed jiffies (1/60 second) since the interpreter started, and TI$ formats them as a six-character HHMMSS string:
// languages/basic/src/expression.rs
/// Get elapsed jiffies (1/60th second ticks) since interpreter started
fn get_jiffies() -> f64 {
TIMER_START_MS.with(|start| {
let start_time = *start.borrow_mut().get_or_insert_with(get_current_time_ms);
let elapsed_ms = get_current_time_ms() - start_time;
(elapsed_ms * 60.0 / 1000.0).floor()
})
}
/// Get elapsed time as HHMMSS string
fn get_time_string() -> String {
let jiffies = get_jiffies();
let total_seconds = (jiffies / 60.0).floor() as u64;
format!("{:02}{:02}{:02}",
(total_seconds / 3600) % 100,
(total_seconds % 3600) / 60,
total_seconds % 60)
}
That matters for old programs that poll the clock in tight loops; if TI is fake, their timing logic collapses in ways that are miserable to diagnose. One difference from the real machine: the C64 rolls TI$ over from 235959 to 000000 at 24 hours, while this takes hours modulo 100. No session I’ve run has come close to either boundary.
Star Trek Auto-Run Is a Timing Problem
The Star Trek backend (555-1702) is mostly a question of when to send RUN. The interpreter reports READY. asynchronously after loading a program, and a RUN sent before that is silently discarded. I gate on the ready prompt and add a short delay:
// web/src/backend/startrek-basic/index.ts
if (type === 'OUTPUT' && this.waitingForReady && text?.includes('READY.')) {
setTimeout(() => this.sendRunCommand(), 100);
}
The 100 ms is a margin on worker scheduling, not a fix for the race — the ready prompt is what actually gates the command. A full handshake would be more correct and would buy nothing I can measure.
Forth: A Dictionary That Compiles Itself
Forth is a dictionary first and a language second. The interpreter is a word map plus an instruction list, and the compiler is another word that emits instructions into that list.
// languages/forth/src/interpreter.rs
pub enum Word {
Primitive(WordFunc),
UserDefined(Rc<Vec<Instruction>>),
Variable(usize),
Constant(i32),
Value(usize),
Does(usize, Rc<Vec<Instruction>>),
}
Every control-flow construct — IF, ELSE, THEN, DO, LOOP — compiles to a short instruction sequence with patchable jump targets. Nested control flow works because of the ControlFlowMarker stack, which tracks which branch needs patching when you close a structure.
Why So Many Tests
Stack languages fail quietly when a push or pop is wrong: there is no type system to catch the mismatch, and the symptom usually appears three operations after the cause. The test density looks excessive until you’ve spent an evening bisecting a stack underflow that started in a word you weren’t looking at.
DO/LOOP and the Return Stack
Counted loops live on the return stack, not the data stack, and I and J peek into it to retrieve loop indices. Loop state therefore never collides with computation, and nested loops need no special machinery. It’s the design decision that makes Forth feel mechanical once you accept the stack discipline.
Scaled Arithmetic Needs 64-bit Intermediates
*/ and */MOD are where overflow stops being theoretical. Multiplying two 32-bit integers can exceed 32 bits before the division brings the result back down, so the implementation uses 64-bit intermediates.
// languages/forth/src/interpreter.rs
fn star_slash(stack: &mut Stack, _output: &mut String) -> Result<(), String> {
let n3 = stack.pop()?;
let n2 = stack.pop()?;
let n1 = stack.pop()?;
if n3 == 0 { return Err("Division by zero".to_string()); }
// Use i64 for intermediate to avoid overflow
let intermediate = (n1 as i64) * (n2 as i64);
let result = (intermediate / (n3 as i64)) as i32;
stack.push(result)
}
That isn’t an optimization. It’s a correctness requirement for fixed-point math on a 32-bit stack.
Scheme: Lists, Environments, and Partial Tail Calls
Scheme’s core rule is list evaluation: evaluate a list by evaluating its head and applying it to its arguments. Everything else — if, define, lambda — is a special form that bends that rule just enough to be useful.
The Scheme interpreter exists as a Rust crate but isn’t exposed through the modem simulator yet. It’s here because building it next to BASIC and Forth showed me something neither of those did on its own.
Values Are Small, But Precise
The Value type encodes exactly the semantics that make cons, car, cdr, and quoting cheap:
// languages/scheme/src/value.rs
pub enum Value {
Number(i64),
Bool(bool),
Symbol(String),
String(String),
Char(char),
Vector(Rc<RefCell<Vec<Value>>>),
Pair(Box<Value>, Box<Value>),
Nil,
Procedure(Rc<Procedure>),
Void,
}
Pair is boxed rather than Rc-wrapped because list structure is value semantics in Scheme: you cons new cells rather than mutating existing ones, mostly. Vectors are Rc<RefCell<...>> because vector-set! exists and mutable sharing is the point.
Environments Are Parent Pointers
Lexical scope is a linked list of environments. That’s the entire model:
// languages/scheme/src/env.rs
pub fn get(env: &EnvRef, name: &str) -> Option<Value> {
if let Some(value) = env.borrow().values.get(name) {
return Some(value.clone());
}
if let Some(parent) = &env.borrow().parent {
return Env::get(parent, name);
}
None
}
When lambda captures its environment it stores the current EnvRef. When called, it creates a child environment and binds the parameters there, and variable lookup walks upward. Closures work because the parent pointer captures the lexical context at definition time rather than call time.
Partial TCO Is Still Worth It
The evaluator uses an iterative loop for tail positions and hands off to apply() for function calls. The tail call optimization is explicitly partial: it takes usable recursion depth from roughly 10–20 to 100–150, but it still bottoms out, because multiple Rust stack frames accumulate across the apply/eval boundary.
// languages/scheme/src/eval.rs
// Tail Call Optimization (TCO) Implementation
//
// This is a PARTIAL TCO implementation that provides ~5-10x improvement
// over baseline (100-150 recursion depth vs. 10-20), but does not achieve
// R5RS-compliant unlimited depth.
Full R5RS conformance needs a trampoline or a continuation-passing architecture. I didn’t build one, because the partial version covers the recursion patterns the emulator actually produces. The honest version of that statement is that this interpreter cannot promise unbounded tail recursion, and no amount of optimizing individual tail forms will change it.
What the Contrast Revealed
BASIC wants a random-access program store, because the line-number lookup is its control flow model. Forth wants a dictionary that compiles into itself, because defining a word and compiling one are the same mechanism. Scheme wants a list evaluator with explicit environments, because the closure model is the runtime.
Once those were clear, the host architecture stopped being a design question. The modem layer only has to carry characters in and text out, run in a worker, and never block the main thread.
The three interpreters share no code except the worker harness, and their internals are structurally incompatible. They plug into the same host interface because that interface asks for nothing beyond byte streams and turn-taking. I went in expecting to find some common abstraction across the three and came out convinced that the useful common layer was much thinner than I’d assumed — and that trying to make it thicker would have made all three worse.
Related Files
Paths in the emulator repository:
web/src/backend/worker-based-interpreter-backend.tsweb/src/backend/basic-interpreter/index.tsweb/src/backend/forth-interpreter/index.tsweb/src/backend/startrek-basic/index.tslanguages/basic/src/languages/forth/src/languages/scheme/src/