Three Languages, One Weekend (Sort Of)
Building BASIC, Forth, and Scheme interpreters in Rust, running them in browser workers, and what that architecture taught me about language shape.
On this page
The Forth interpreter has 396 tests for roughly 150 words. That ratio—more than two tests per word—isn’t paranoia. It’s what happens when you build a stack language and realize that a single push/pop mismatch can corrupt everything downstream without a clear error. I wrote those tests one at a time, each after a bug that taught me something about Forth’s shape.
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. The project took more than a weekend, but the shape of the work really was a weekend-scale loop: implement a subsystem, expose a thin WASM surface, route modem bytes through it, repeat.
The reason for three languages was not coverage; it was contrast. BASIC, Forth, and Scheme take opposite positions on syntax, memory, and control flow. Implementing all three in the same host architecture turns their trade-offs into mechanical facts you can measure, rather than opinions you defend.
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 needs to see the new URL() inside the new Worker() call to bundle the worker correctly—a build-time constraint that ripples through every backend. Once you accept that constraint, a language backend becomes just a worker path and 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 host should be boring. The interesting differences are inside the interpreters.
BASIC: A Line-Numbered Control Graph
BASIC looks like a linear program, but line numbers turn it into a random-access control graph. GOTO 500 is not a jump instruction; it’s a dictionary lookup. That shape—program as map, not list—shows up in every subsystem: parsing, storage, and execution all treat the program as addressable by line number.
Two Tokenizers, One Truth
The interpreter uses a hand-rolled tokenizer with years of edge-case fixes. But I also built a Pest-based replacement and keep it in lockstep through cross-checking tests. The manual parser has the battle scars; the Pest parser gives me a second implementation to verify correctness before any 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.
The constraint—“don’t regress the edge cases”—keeps the migration staged indefinitely. That’s deliberate, not procrastination.
TI and TI$ Are Real, Not Placeholders
Many BASIC interpreters fake system variables. This one doesn’t. TI tracks elapsed time since interpreter start in jiffies (1/60th of a second); TI$ returns a six-character HHMMSS string with hours modulo 100, matching the Commodore 64’s behaviour exactly.
// 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 detail matters for old programs that poll time in tight loops. If TI is fake, timing logic collapses and classic games break in ways that are hard to diagnose.
Star Trek Auto-Run Is a Timing Problem
The Star Trek backend (555-1702) is mostly about when to send RUN. The interpreter reports READY. asynchronously after loading a program, and sending RUN too early is silently ignored. I gate on the ready prompt and add a short delay to avoid the race.
// web/src/backend/startrek-basic/index.ts
if (type === 'OUTPUT' && this.waitingForReady && text?.includes('READY.')) {
setTimeout(() => this.sendRunCommand(), 100);
}
The 100ms delay is not a hack; it’s an explicit statement about scheduling and worker readiness. The alternative—polling or handshaking—would add complexity for no reliability gain.
Forth: A Dictionary That Compiles Itself
Forth is a dictionary first, a language second. The interpreter is a word map plus an instruction list, and the compiler is just 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 small instruction sequence with patchable jump targets. The ControlFlowMarker stack is the only reason nested control flow works; it tracks which branch needs patching when you close a structure.
Test Density as Survival
The interpreter implements roughly 150 words and has 396 tests. The ratio is deliberate. Stack languages fail silently when you get a push or pop wrong—there’s no type system to catch the mismatch, and the error often manifests three operations later. I write tests at a higher density than the code seems to deserve because the code has proven it deserves exactly that density.
DO/LOOP and the Return Stack
Counted loops live on the return stack, not the data stack. I and J peek into the return stack to retrieve loop indices. That design means loop state never collides with computation, and nested loops work without special machinery. The simplicity is the reason Forth feels “mechanical” once you accept the stack discipline.
Scaled Arithmetic Needs 64-bit Intermediates
*/ and */MOD are the small operators that force you to handle overflow correctly. Multiplying two 32-bit integers can exceed 32 bits before the division brings the result back down. The implementation uses 64-bit intermediates so fixed-point arithmetic doesn’t explode mid-calculation.
// 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)
}
This is not an optimisation. It’s a correctness requirement for fixed-point math on a 32-bit stack.
Scheme: Lists, Environments, and Partial Tail Calls
Scheme’s core behaviour is list evaluation: evaluate a list by evaluating its head, then applying it to its arguments. Everything else—if, define, lambda—is a special form that bends that rule just enough to express the language.
The Scheme interpreter exists as a Rust crate but isn’t yet exposed via the modem simulator. It’s included here because implementing it alongside BASIC and Forth revealed something about language shape that neither alone would have shown.
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, you don’t mutate 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. Variable lookup walks upward. Closures work because the parent pointer captures the lexical context at definition time, not 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 optimisation is explicitly partial: it extends recursion depth by a factor of roughly 5–10 (from ~15 calls to ~100–150) but still bottoms out because multiple Rust stack frames accumulate through 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 needs a trampoline or continuation-passing architecture. I didn’t build one because the partial version already handles the recursion patterns I care about, and unbounded recursion isn’t a requirement for the emulator’s use cases.
That’s a real constraint: without a trampoline, you cannot promise unbounded tail recursion, even if you optimise tail forms.
What the Contrast Revealed
The deepest lesson wasn’t about syntax or parsing. It was about shapes.
BASIC wants a random-access program store—the line-number lookup defines its control flow model. Forth wants a dictionary that compiles to itself—defining a word and compiling it are the same mechanism. Scheme wants a list evaluator with explicit environments—the closure model is the entire runtime.
Once I saw those shapes, the host architecture fell into place. The modem layer only needs to honour the smallest contract—characters in, text out—as long as that contract runs in a worker and never blocks the main thread.
The three interpreters share no code except the worker harness. Their internals are structurally incompatible. But they plug into the same host interface because the interface asks for nothing more than byte streams and turn-taking.
That’s the real insight: when you build interpreters to compare, you stop asking “which language is better” and start asking “what shape does this language want to be?” The answers are incompatible, and that’s fine. The job of the host is to stay out of the way.
Related Files
These paths are relative to the emulator.ca 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/