Every programming language, at its core, is an instruction set. The syntax and semantics that developers interact with are a facade over a finite set of operations that the machine actually executes. This article documents every opcode in FLIN's bytecode -- the complete instruction set that powers the virtual machine.
FLIN's instruction set spans 16 categories and over 100 opcodes, ranging from basic stack manipulation to AI-powered semantic queries. Each opcode is a single byte, followed by zero or more operand bytes. This compact encoding keeps bytecode files small and instruction dispatch fast.
Understanding the opcode set is essential for anyone who wants to know what FLIN actually does when you write count++ or save user or {for todo in todos}. This is the language beneath the language.
---
Instruction Encoding
Before listing the opcodes, it helps to understand how they are encoded in the bytecode stream. FLIN uses variable-length instructions with five formats:
Format 0 -- No operands (1 byte):
[opcode]
Examples: Add, Sub, Mul, Pop, Dup, Return, HaltFormat 1 -- One u8 operand (2 bytes): [opcode] [u8] Examples: LoadLocal, StoreLocal (slots 0-255)
Format 2 -- One u16 operand (3 bytes): [opcode] [u16 little-endian] Examples: LoadConst, LoadGlobal, Jump
Format 3 -- One u32 operand (5 bytes): [opcode] [u32 little-endian] Examples: JumpFar, CallNative
Format 4 -- Two u8 operands (3 bytes): [opcode] [u8] [u8] Examples: Call (arity, const_idx) ```
All multi-byte values are little-endian. The most common instructions (arithmetic, stack ops) are Format 0 -- a single byte with no operands. This means typical FLIN bytecode averages about 1.5 bytes per instruction, which is remarkably compact.
---
Category 1: Control Flow (0x00-0x0F)
Control flow instructions manage the execution path -- halting, jumping, calling, and returning.
| Byte | Mnemonic | Operands | Stack Effect | Description |
|---|---|---|---|---|
| 0x00 | Halt | -- | -- | Stop execution, return top of stack |
| 0x01 | Nop | -- | -- | No operation (used for padding) |
| 0x02 | Jump | u16 addr | -- | Unconditional jump to address |
| 0x03 | JumpIfTrue | u16 addr | cond -> | Pop and jump if truthy |
| 0x04 | JumpIfFalse | u16 addr | cond -> | Pop and jump if falsy |
| 0x05 | JumpIfNone | u16 addr | val -> | Pop and jump if None |
| 0x06 | Call | u8 arity | fn, args... -> result | Call function with N args |
| 0x07 | CallNative | u16 idx | args... -> result | Call built-in function |
| 0x08 | Return | -- | result -> | Return from function |
| 0x09 | JumpFar | u32 addr | -- | Jump with 32-bit address |
Halt is always the last instruction in a program. Without it, the VM would run past the end of the bytecode array. The compiler ensures every code path ends with either Halt or Return.
JumpIfNone exists specifically for optional value handling. In FLIN, many operations can return None (a database lookup that finds nothing, a list access that is out of bounds), and checking for None is common enough to deserve its own instruction rather than a two-instruction sequence of IsNone + JumpIfTrue.
---
Category 2: Stack Operations (0x10-0x1F)
Stack instructions manipulate the operand stack directly.
| Byte | Mnemonic | Operands | Stack Effect | Description |
|---|---|---|---|---|
| 0x10 | LoadConst | u16 idx | -> value | Push constant from pool |
| 0x11 | Pop | -- | value -> | Discard top of stack |
| 0x12 | Dup | -- | v -> v, v | Duplicate top |
| 0x13 | Dup2 | -- | a, b -> a, b, a, b | Duplicate top two |
| 0x14 | Swap | -- | a, b -> b, a | Swap top two |
| 0x15 | Over | -- | a, b -> a, b, a | Copy second to top |
| 0x16 | LoadNone | -- | -> none | Push None |
| 0x17 | LoadTrue | -- | -> true | Push true |
| 0x18 | LoadFalse | -- | -> false | Push false |
| 0x19 | LoadInt0 | -- | -> 0 | Push integer 0 |
| 0x1A | LoadInt1 | -- | -> 1 | Push integer 1 |
| 0x1B | LoadIntN1 | -- | -> -1 | Push integer -1 |
| 0x1C | LoadSmallInt | i8 val | -> val | Push small int (-128..127) |
The specialised constant instructions (LoadNone, LoadTrue, LoadFalse, LoadInt0, LoadInt1, LoadIntN1, LoadSmallInt) are optimisations. Instead of the three-byte LoadConst followed by a constant pool lookup, these single- or two-byte instructions push commonly used values directly. The compiler emits them automatically when it detects a constant that matches.
LoadSmallInt with an i8 operand covers integers from -128 to 127. This means loop counters, array indices, and most small numeric literals are encoded in just two bytes.
---
Category 3: Local Variables (0x20-0x2F)
// Examples of local variable instructions in use:
LoadLocal 0 // Push first local onto stack
StoreLocal 1 // Pop stack into second local
IncrLocal 0 // Increment first local in place
LoadLocalPush 2 // Push third local twice (load + dup)| Byte | Mnemonic | Operands | Stack Effect | Description |
|---|---|---|---|---|
| 0x20 | LoadLocal | u8 slot | -> value | Load local variable |
| 0x21 | StoreLocal | u8 slot | value -> | Store local variable |
| 0x22 | LoadLocal16 | u16 slot | -> value | Load local (extended range) |
| 0x23 | StoreLocal16 | u16 slot | value -> | Store local (extended range) |
| 0x24 | IncrLocal | u8 slot | -- | Increment local by 1 |
| 0x25 | DecrLocal | u8 slot | -- | Decrement local by 1 |
| 0x26 | LoadLocalPush | u8 slot | -> val, val | Load and duplicate |
IncrLocal and DecrLocal are fused instructions. The naive way to increment a local variable is LoadLocal 0; LoadInt1; Add; StoreLocal 0 -- four instructions, four dispatch cycles. IncrLocal 0 does the same thing in one instruction, two bytes. In a tight loop, this saves 75% of the overhead per iteration.
---
Category 4: Global Variables (0x30-0x3F)
| Byte | Mnemonic | Operands | Stack Effect | Description |
|---|---|---|---|---|
| 0x30 | LoadGlobal | u16 name_idx | -> value | Load by name from constant pool |
| 0x31 | StoreGlobal | u16 name_idx | value -> | Store by name |
| 0x32 | LoadGlobalDirect | u16 slot | -> value | Load by slot (optimised) |
| 0x33 | StoreGlobalDirect | u16 slot | value -> | Store by slot (optimised) |
LoadGlobalDirect and StoreGlobalDirect bypass the hash map lookup by using a numeric slot, assigned by the compiler when it can determine the global's index at compile time. This is an optimisation path -- the compiler falls back to name-based lookup when the global is defined dynamically.
---
Category 5: Arithmetic (0x40-0x4F)
| Byte | Mnemonic | Operands | Stack Effect | Description |
|---|---|---|---|---|
| 0x40 | Add | -- | a, b -> result | Addition (also string concatenation) |
| 0x41 | Sub | -- | a, b -> result | Subtraction |
| 0x42 | Mul | -- | a, b -> result | Multiplication |
| 0x43 | Div | -- | a, b -> result | Division (Float result) |
| 0x44 | Mod | -- | a, b -> result | Modulo |
| 0x45 | Neg | -- | a -> result | Negation |
| 0x46 | Incr | -- | a -> result | Increment by 1 |
| 0x47 | Decr | -- | a -> result | Decrement by 1 |
| 0x48 | Pow | -- | a, b -> result | Exponentiation |
| 0x49 | IntDiv | -- | a, b -> result | Integer division |
Add does double duty: for numbers, it performs addition. For strings, it performs concatenation. The VM checks the types of both operands at runtime and dispatches accordingly. This is one of the few places where FLIN's VM is dynamically typed -- the type checker has already verified that the operands are compatible, but the VM still needs to handle both cases.
Div always returns a Float, even for integer operands. IntDiv returns an Int, truncating the result. This matches the mathematical distinction between division (which can produce fractions) and integer division (which cannot).
---
Categories 6-7: Comparison and Logic (0x50-0x6F)
// Comparison chain example:
// if a < b and b < c
LoadLocal 0 // a
LoadLocal 1 // b
Lt // a < b -> bool
LoadLocal 1 // b
LoadLocal 2 // c
Lt // b < c -> bool
And // (a < b) and (b < c) -> bool| Byte | Mnemonic | Stack Effect | Description |
|---|---|---|---|
| 0x50 | Eq | a, b -> bool | Equality (deep comparison for objects) |
| 0x51 | NotEq | a, b -> bool | Inequality |
| 0x52 | Lt | a, b -> bool | Less than |
| 0x53 | LtEq | a, b -> bool | Less than or equal |
| 0x54 | Gt | a, b -> bool | Greater than |
| 0x55 | GtEq | a, b -> bool | Greater than or equal |
| 0x56 | IsNone | a -> bool | Test for None |
| 0x57 | IsNotNone | a -> bool | Test for not None |
| 0x58 | Compare | a, b -> int | Three-way comparison (-1, 0, 1) |
| 0x60 | And | a, b -> bool | Logical AND |
| 0x61 | Or | a, b -> bool | Logical OR |
| 0x62 | Not | a -> bool | Logical NOT |
| 0x63-0x68 | Bitwise | a, b -> int | BitAnd, BitOr, BitXor, BitNot, ShiftLeft, ShiftRight |
A critical detail about Eq: it performs deep comparison for objects. Two strings with the same content but different ObjectId values are equal. This required a dedicated values_equal() helper in the VM that dereferences objects and compares their contents, rather than comparing their heap addresses. Getting this wrong was a bug we caught in Session 026 -- "hello" == "hello" was returning false because it was comparing ObjectId values.
---
Category 8: Objects and Fields (0x70-0x7F)
| Byte | Mnemonic | Operands | Stack Effect | Description |
|---|---|---|---|---|
| 0x70 | CreateObject | u8 count | vals... -> obj | Create object with N fields |
| 0x71 | GetField | u16 name_idx | obj -> value | Get field by name |
| 0x72 | SetField | u16 name_idx | obj, val -> | Set field by name |
| 0x73 | HasField | u16 name_idx | obj -> bool | Check field existence |
| 0x74 | DeleteField | u16 name_idx | obj -> | Remove field |
| 0x75 | GetFieldDyn | -- | obj, name -> val | Dynamic field access |
| 0x76 | SetFieldDyn | -- | obj, name, val -> | Dynamic field set |
| 0x77 | CreateEntity | u16 type_idx, u8 count | vals... -> entity | Create entity instance |
GetFieldDyn and SetFieldDyn are for computed property access -- when the field name is not known at compile time (e.g., obj[fieldName]). The field name is popped from the stack as a string value.
CreateEntity is the bridge between objects and FLIN's data persistence layer. It creates an EntityInstance with a type tag, an auto-generated ID, and the specified fields. The entity is allocated on the heap and can be saved to FlinDB with the Save instruction.
---
Category 9: Lists and Maps (0x80-0x8F)
| Byte | Mnemonic | Operands | Stack Effect | Description |
|---|---|---|---|---|
| 0x80 | CreateList | u16 count | items... -> list | Create list from stack values |
| 0x81 | GetIndex | -- | list, idx -> value | Index into list |
| 0x82 | SetIndex | -- | list, idx, val -> | Set list element |
| 0x83 | ListLen | -- | list -> int | List length |
| 0x84 | ListPush | -- | list, val -> | Append to list |
| 0x85 | ListPop | -- | list -> val | Remove and return last |
| 0x86 | ListConcat | -- | a, b -> list | Concatenate two lists |
| 0x87 | ListSlice | -- | list, start, end -> list | Sub-list extraction |
| 0x88 | CreateMap | u16 count | k, v... -> map | Create map from key-value pairs |
| 0x89 | MapGet | -- | map, key -> value | Get map value |
| 0x8A | MapSet | -- | map, key, val -> | Set map entry |
| 0x8B | MapHas | -- | map, key -> bool | Key existence check |
| 0x8C | MapDelete | -- | map, key -> | Remove entry |
| 0x8D | MapKeys | -- | map -> list | All keys as list |
| 0x8E | MapValues | -- | map -> list | All values as list |
CreateList pops count values from the stack (in reverse order) and creates a list. CreateMap pops count * 2 values (alternating keys and values). Both allocate on the heap and push a Value::Object(ObjectId) onto the stack.
---
Category 10: Entity Operations (0x90-0x9F)
These opcodes interact with FlinDB, FLIN's built-in database:
| Byte | Mnemonic | Operands | Stack Effect | Description |
|---|---|---|---|---|
| 0x90 | Save | -- | entity -> | Persist entity to FlinDB |
| 0x91 | Delete | -- | entity -> | Remove entity from FlinDB |
| 0x92 | QueryAll | u16 type_idx | -> list | Get all entities of a type |
| 0x93 | QueryFind | u16 type_idx | id -> entity? | Find by ID |
| 0x94 | QueryWhere | u16 type_idx | predicate -> list | Filter entities |
| 0x95 | QueryFirst | u16 type_idx | -> entity? | First entity of type |
| 0x96 | QueryCount | u16 type_idx | -> int | Count entities |
| 0x97 | QueryOrder | u16 type, u16 field | dir -> list | Ordered query |
| 0x98 | EntityId | -- | entity -> id | Extract entity ID |
| 0x99 | EntityType | -- | entity -> string | Extract entity type name |
In Session 011, Save and QueryAll were implemented against an in-memory HashMap. The real FlinDB storage engine (ZEROCORE) would come later, but the opcodes and their stack effects were fixed from the start.
---
Category 11: View Operations (0xA0-0xAF)
Covered in detail in the previous article. Sixteen instructions for creating elements, binding text and attributes, handling events, and conditional/loop rendering.
---
Categories 12-13: Intent and Temporal (0xB0-0xCF)
Intent operations power FLIN's AI capabilities:
| Byte | Mnemonic | Stack Effect | Description |
|---|---|---|---|
| 0xB0 | Ask | query -> list | Natural language database query |
| 0xB1 | Search | query, limit -> list | Semantic search in entity fields |
| 0xB2 | SearchMulti | query, limit, fields... -> list | Multi-field semantic search |
| 0xB3 | Embed | text -> embedding | Generate vector embedding |
Temporal operations power FLIN's time-travel queries:
| Byte | Mnemonic | Stack Effect | Description |
|---|---|---|---|
| 0xC0 | AtVersion | entity, version -> entity | Access a specific version |
| 0xC1 | AtTime | entity -> entity | Access entity at a time reference |
| 0xC2 | AtDate | entity, date -> entity | Access entity at a specific date |
| 0xC3 | History | entity -> list | Get all versions |
| 0xC4-0xC7 | Time loads | -> time | LoadNow, LoadToday, LoadYesterday, ChangesSince |
---
Category 14: Built-in Functions (0xD0-0xEF)
// Built-in function opcodes:
Print = 0xD0, // Debug print
ToString = 0xD1, // Convert to text
ToInt = 0xD2, // Convert to integer
ToFloat = 0xD3, // Convert to float
TypeOf = 0xD4, // Get type name string
Len = 0xD5, // Length of string/list/map// Extended built-ins (0xE0-0xEF): Replace = 0xE0, // String replace Abs = 0xE1, // Math absolute value Floor = 0xE2, // Math floor Ceil = 0xE3, // Math ceiling Round = 0xE4, // Math round Min = 0xE5, // Math min Max = 0xE6, // Math max Sqrt = 0xE7, // Square root PowFn = 0xE8, // Power function ListFirst = 0xE9, // First list element ListLast = 0xEA, // Last list element ListReverse = 0xEB, // Reverse list ListSort = 0xEC, // Sort list ListMap = 0xED, // Higher-order map ListFilter = 0xEE, // Higher-order filter ListReduce = 0xEF, // Higher-order reduce ```
The last three (ListMap, ListFilter, ListReduce) are the HOF opcodes that trigger the continuation-based iteration machinery described in the closures article.
---
The Opcode Space
FLIN's opcode space is a single byte (0x00-0xFF), divided into 16 ranges of 16 opcodes each. Currently, about 110 of the 256 possible opcodes are assigned. This leaves room for approximately 146 future instructions without breaking the single-byte encoding.
The 0xF0-0xFF range is reserved for debug and special instructions:
| Byte | Mnemonic | Description |
|---|---|---|
| 0xF0 | Breakpoint | Debugger breakpoint |
| 0xF1 | SourceLoc | Source location marker (for stack traces) |
| 0xFE | Extended | Extended opcode prefix (for future 2-byte opcodes) |
| 0xFF | Invalid | Invalid instruction (always triggers an error) |
The Extended prefix (0xFE) is a forward-looking design. If FLIN ever exhausts the single-byte opcode space, Extended followed by a second byte gives access to 256 additional opcodes without changing the encoding of existing instructions.
---
Design Philosophy
Three principles guided the opcode design:
Orthogonality. Each opcode does one thing. Add adds. LoadLocal loads a local. CreateElement creates an element. There are no instructions that combine unrelated operations (with the deliberate exception of fused instructions like IncrLocal, which combine a load, an add, and a store for performance).
Stack discipline. Every opcode documents its stack effect: how many values it consumes and how many it produces. The compiler uses these stack effects to verify that the generated bytecode is balanced -- every code path leaves the stack at the same height. A bug in the compiler that produces unbalanced bytecode would be caught by this verification.
Extension without breakage. The opcode ranges are grouped by category, with gaps left for future additions. Entity operations have room for more query types. View operations have room for more DOM manipulation instructions. The reserved ranges ensure that FLIN's bytecode format can evolve without invalidating existing compiled files.
---
This is Part 25 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: [26] Hot Module Reload in 42ms -- how file changes go from disk to browser in under 50 milliseconds.