There is a moment in the life of every programming language when it stops being a toy and starts being real. For FLIN, that moment came on January 3, 2026, in Session 026, when a .flin file compiled to bytecode, executed in the VM, produced HTML, was served over HTTP, and rendered interactively in Google Chrome.
A counter button that actually counted. A reactive binding that actually updated. A view block that actually produced a web page.
It took 26 sessions and thousands of lines of Rust to reach this point. This article is about that session -- what worked, what broke, how we fixed it, and what it felt like to see FLIN render for the first time.
---
The Setup
By the start of Session 026, FLIN had:
- A lexer, parser, and type checker that could process
.flinfiles. - A code generator that emitted bytecode.
- A virtual machine that could execute that bytecode.
- A garbage collector that managed heap memory.
- An HTTP server that could serve static files.
- A view renderer that could produce HTML from view opcodes.
- A reactivity runtime that could track state changes.
What it did not have was proof that all of these pieces worked together. Each component had been tested in isolation -- unit tests for the lexer, integration tests for the VM, manual tests for the HTTP server. But the end-to-end pipeline, from .flin source to browser pixel, had never been exercised.
Session 026 was the integration test.
---
The Welcome Page
We created examples/welcome.flin, a showcase page designed to exercise every feature:
count = 0view {
Welcome to FLIN
The language that writes apps like it's 1995.
Interactive Counter
Count: {count}
This page exercises:
- Global variable declaration (
count = 0). - View rendering with nested HTML elements.
- Static attributes (
class="welcome"). - Static text content.
- Reactive text binding (
{count}). - Event handlers with state mutation (
click={count++}). - Event handlers with state reset (
click={count = 0}).
If this page renders correctly, the entire pipeline works. If it breaks, the error tells us which layer failed.
---
Bug 1: Stack Underflow in Match Expressions
The first attempt to compile and run the welcome page crashed with a stack underflow error. The stack trace pointed to the match expression handler in the code generator.
The root cause was subtle: the emit_match function in src/codegen/emitter.rs assumed that the JumpIfFalse instruction does not pop the condition value from the stack. In reality, JumpIfFalse always pops the condition.
The emitter was emitting an extra Pop instruction after JumpIfFalse, attempting to clean up a value that was already gone. This left the stack one element short, and the next instruction that tried to pop a value hit an empty stack.
The fix was removing two erroneous Pop instructions:
// Before (broken):
emit(JumpIfFalse, else_addr);
emit(Pop); // ERROR: condition already popped by JumpIfFalse// After (correct): emit(JumpIfFalse, else_addr); // No Pop needed -- JumpIfFalse consumed the condition ```
Two lines removed. Two hours of debugging. This is the nature of VM bugs: the symptom (stack underflow) appears far from the cause (an extra Pop ten instructions earlier), and the only way to find it is to trace the stack state instruction by instruction.
---
Bug 2: String Comparison Failure
With the stack underflow fixed, the welcome page compiled and the VM produced HTML. But the reactive counter did not work. Clicking the "Increment" button did nothing.
Investigation revealed that the match expression for view rendering was comparing string values by ObjectId rather than by content. Two strings with the same content but different heap allocations were not equal:
// Before (broken):
OpCode::Eq => {
let b = self.pop();
let a = self.pop();
self.push(Value::Bool(a == b)); // Compares ObjectId, not content
}Value::Object(ObjectId(5)) and Value::Object(ObjectId(12)) are not equal even if both point to the string "click". The == operator on Value compared the enum variants structurally, which for Object variants meant comparing the ObjectId (a heap index).
The fix was a dedicated values_equal helper that dereferences objects and compares their content:
fn values_equal(&self, a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::None, Value::None) => true,
(Value::Bool(a), Value::Bool(b)) => a == b,
(Value::Int(a), Value::Int(b)) => a == b,
(Value::Float(a), Value::Float(b)) => a == b,
(Value::Int(a), Value::Float(b)) => (*a as f64) == *b,
(Value::Float(a), Value::Int(b)) => *a == (*b as f64),
(Value::Object(a_id), Value::Object(b_id)) => {
if a_id == b_id {
return true; // Same object
}
// Dereference and compare content
match (self.get_object(*a_id), self.get_object(*b_id)) {
(Some(a_obj), Some(b_obj)) => match (&a_obj.data, &b_obj.data) {
(ObjectData::String(a), ObjectData::String(b)) => a == b,
(ObjectData::List(a), ObjectData::List(b)) => {
a.len() == b.len() &&
a.iter().zip(b.iter()).all(|(x, y)| self.values_equal(x, y))
}
_ => false,
},
_ => false,
}
}
_ => false,
}
}This function handles the easy cases (primitives compared by value) and the hard case (objects compared by dereferenced content). For strings, it compares the actual string data. For lists, it recursively compares each element.
The if a_id == b_id { return true; } shortcut is important: if two values point to the same heap object, they are trivially equal without dereferencing. This handles the common case where a variable is compared to itself.
---
The Moment It Worked
With both bugs fixed, we rebuilt the FLIN binary and ran:
./target/release/flin dev examples/welcome.flinThe terminal printed:
[FLIN] Dev server running at http://localhost:3000
[FLIN] Watching examples/welcome.flin for changesOpening http://localhost:3000 in Chrome showed the welcome page. The heading said "Welcome to FLIN." The counter showed "Count: 0." Two buttons: "Increment" and "Reset."
Clicking "Increment" changed the count to 1. Clicking again: 2. Again: 3. Clicking "Reset" set it back to 0. Every click, the {count} binding updated instantly.
It worked.
A .flin file, written in a language that did not exist eight days earlier, compiled to bytecode by a compiler written from scratch in Rust, executed by a virtual machine with a garbage collector, rendered to HTML by a view engine with reactive annotations, served over HTTP by an embedded web server, and made interactive in the browser by a 50-line JavaScript runtime.
Twenty-six sessions. No frameworks. No dependencies beyond Rust's standard library and a handful of crates. No human engineers besides Thales. One AI CTO (Claude) generating the code.
---
The Welcome Page in Production
The welcome page that launched on that day was more than a counter demo. It included:
- The FLIN logo, served as a static asset from the
public/directory. - A "Five Pillars" section describing FLIN's core concepts: Entities, Views, Actions, Intents, and Time.
- A quick-start guide with commands.
- A footer with the creator signature.
- A dark theme with FLIN's colour palette.
All of it rendered from a single .flin file. All of it reactive. All of it served by a Rust binary that was under 20 MB.
---
What the Debugging Taught Us
The two bugs in Session 026 were both interaction bugs -- they appeared only when multiple subsystems worked together. The stack underflow was a code generator bug that only manifested during VM execution. The string comparison was a VM bug that only manifested when the view renderer compared attribute values.
Unit tests did not catch them because unit tests test components in isolation. Integration tests should have caught them, but the integration test suite had not yet covered the view rendering pipeline.
The lesson was clear: end-to-end testing, from source code to browser render, must be a first-class concern. After Session 026, we committed to testing the full pipeline for every new feature, not just the individual components.
---
The Version Bump
Session 026 warranted a version bump from v0.26.0 to v0.50.0. This was not because of the number of features added (two bug fixes and a welcome page). It was because the project had crossed a qualitative threshold.
Before v0.50.0, FLIN was a compiler and a VM. After v0.50.0, FLIN was a web framework. It could take a .flin file and produce a working, interactive web page. The fact that the web page was simple -- a counter -- did not matter. The pipeline was complete. Everything that came after was elaboration, not invention.
---
Known Issues
Session 026 was honest about what did not work yet:
.to_floatmethod returned null. String-to-float conversion was not implemented, which meant the calculator example still failed.- Lambda parameter handling. Functions showed as
nullin the runtime state, meaning callbacks with parameters did not work correctly in views. - Action blocks. The
name = { ... }syntax for named action blocks needed more testing.
These were real bugs, documented in the session log with priority labels (P0, P1, P2). They would be fixed in subsequent sessions. But they did not diminish the achievement of Session 026: FLIN rendered in the browser.
---
The Server That Made It Possible
The browser render would not have happened without the HTTP server that served the page. The FLIN dev server, implemented in src/server/http.rs, was deliberately minimal:
- Route
"/"and"/index.html"to the FLIN renderer. - Route all other paths to the
public/directory for static assets. - Return 404 for everything else.
The serve_flin() function was the critical path: read the FLIN source file, compile it, render the views, wrap the output in an HTML document (with , , , and the reactivity runtime ), and return it as the HTTP response.
If compilation failed, the server did not return an empty page or a generic error. It generated a styled error page with the error message, syntax-highlighted source code, and a line number. This inline error display meant the developer could see what went wrong without switching to the terminal.
The server recompiled the FLIN source on every request. There was no caching. This sounds wasteful, but compilation took 15ms. For a development server where the user is testing changes, 15ms of recompilation is invisible. And it meant that every page load showed the latest code -- no stale cache, no "did I forget to rebuild?" confusion.
---
What It Means
The first browser render is the inflection point of any frontend technology. Before it, the technology is academic -- interesting but unproven. After it, it is practical -- flawed but real.
For FLIN, the first browser render proved several things simultaneously:
1. The architecture works. A stack-based VM written in Rust can produce HTML fast enough for interactive web applications.
2. The reactivity model works. A JavaScript proxy with data-flin-bind annotations is sufficient for responsive UI updates.
3. The single-file model works. A developer can write one .flin file and get a working web page.
4. The CEO + AI CTO model works. Thales and Claude, working from Abidjan, built a programming language that renders in the browser. No team of ten. No year-long development cycle. Twenty-six sessions.
There would be hundreds of bugs to fix, dozens of features to add, and months of polish before FLIN was production-ready. But after Session 026, the question was no longer "can this work?" It was "how good can this get?"
The statistics tell the story in numbers: 617 tests passing, 4 files changed, 200 lines added, 2 critical bugs fixed. But numbers do not capture the psychological shift. Before Session 026, every session was building towards an uncertain goal. After Session 026, every session was improving a working product. The difference in motivation, in focus, in the quality of decisions made under that certainty, was enormous.
---
This is Part 29 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO built a programming language from scratch.
Next up: [30] Parallel Agents in the FLIN Runtime -- how we used concurrent execution and message passing to build features in parallel.