Watch the pipeline
Every stage that this page describes — read, parse, run — is what you see below. The animation loops through fourteen statement-executions of a tiny FizzBuzz, captured from the same snapshot model the playground exposes.
1i = 12while (i <= 3) {3 if (i == 3) {4 coutln("Fizz")5 } else {6 coutln(i)7 }8 i = i + 19}
Source — characters
The whole program is just text. Whitespace matters only inside strings; ROT uses C-style braces, not Python-style indentation.
i = 1
while (i <= 3) {
if (i == 3) {
coutln("Fizz")
} else {
coutln(i)
}
i = i + 1
}Nothing is happening yet. The file is bytes on disk. Two things will turn it into something runnable: a lexer that groups characters into tokens, and a parser that groups tokens into a tree.
Lexer — tokens
The lexer reads characters one at a time. When it sees a digit it consumes more digits until something stops being a digit, and emits a NUMBER token. When it sees a letter it consumes an identifier, then checks the keyword table to decide if it's WHILE or just IDENT. Every token carries its source line and column.
IDENT(i) OP(=) NUMBER(1)
WHILE LPAREN IDENT(i) LE NUMBER(3) RPAREN LBRACE
IF LPAREN IDENT(i) EQ NUMBER(3) RPAREN LBRACE
IDENT(coutln) LPAREN STRING("Fizz") RPAREN
RBRACE ELSE LBRACE
IDENT(coutln) LPAREN IDENT(i) RPAREN
RBRACE
IDENT(i) OP(=) IDENT(i) OP(+) NUMBER(1)
RBRACESource: rot/lexer.py — around 360 lines, no regex, no parser-generator. Just a big while loop and a character-dispatch table.
Parser — AST
The parser turns the flat token stream into a tree of typed nodes — an abstract syntax tree. Statements use recursive descent (one function per grammar rule). Expressions use Pratt parsing, which handles operator precedence cleanly without a separate precedence table.
Program
└── statements
├── Assign(target=Var("i"), value=Lit(1))
└── WhileStmt(
cond = BinaryOp("<=", Var("i"), Lit(3)),
body = Block([
IfStmt(
cond = BinaryOp("==", Var("i"), Lit(3)),
then = Block([Call(Var("coutln"), [Lit("Fizz")])]),
else_ = Block([Call(Var("coutln"), [Var("i")])])
),
Assign(
target = Var("i"),
value = BinaryOp("+", Var("i"), Lit(1))
)
])
)Source: rot/syntax.py. Every node is a @dataclass with line and column fields, so runtime errors can point back to the exact source position.
Interpreter — snapshots
The tree-walking interpreter visits each AST node in order, and executes it. Statements like Assign mutate the environment; Call pushes a new frame onto the scope chain; If evaluates its condition and dispatches.
For the playground, the interpreter also yields a snapshot after every statement — a frozen view of the scope chain, accumulated stdout, and the source position. That snapshot list is what the “Step” button walks through.
Source: rot/interpreter.py — about 1,100 lines. The fast path (execute()) is the default; the snapshot path (iter_execute()) is opt-in and powers the playground.
Bytecode — opcodes
ROT also ships an opt-in bytecode compiler and stack VM. The compiler lowers the same AST into a flat array of 38 opcodes; the VM executes them with a value stack and a frame stack — the same model CPython, Lua, and the JVM use, just smaller.
- 0 LOAD_CONST 42
- 1 STORE_NAME i
- 2 LOAD_NAME i
- 3 RETURN
# the i = 1 line compiles to:
0 LOAD_CONST 1
2 STORE_NAME i
# while (i <= 3) { ... } compiles to:
4 LOAD_NAME i
6 LOAD_CONST 3
8 LE
9 JUMP_IF_FALSE <end-of-loop>
...Source: rot/codegen.py and rot/vm.py. Try it from the CLI with python -m rot --vm examples/fizzbuzz.rot.
Output — stdout
cout and coutln write to a captured buffer that the playground streams back to the browser, one chunk at a time. There's no magic: the implementation is print(...) into a StringIO.
Where to look next
- Open the playground — type code, hit Animate, step through it statement by statement.
- The language reference — every keyword, operator, builtin, and surface feature.
- The source on GitHub — ~25,000 lines across language and site. The lexer fits in one sitting.