Iron Language Reference
Complete guide to the Iron programming language — a compiled, strongly-typed language built for clarity, legibility, and performance.
Overview
Iron is a compiled, performant programming language focused on clarity, legibility, and performance. It compiles to C and produces native standalone binaries.
- File extension:
.iron - CLI:
iron build,iron run,iron test,iron check,iron fmt - Compiles to C, then to native binaries via clang/gcc
- Platforms: macOS (arm64, x86_64), Linux, Windows
Philosophy
- Concise: few keywords that express great meaning
- Strong types, no implicit conversions
- Manual memory management with built-in helpers (defer, ref counting)
- Brace-delimited blocks
- Null safety by default
- Legibility over magic
- No operator overloading, no hidden control flow
Primitive Types
All primitive types start with uppercase.
Int -- platform int (64-bit) Int8 Int16 Int32 Int64 UInt -- platform unsigned UInt8 UInt16 UInt32 UInt64 Float -- f64 Float32 Float64 Bool -- true / false String -- always unicode, always iterable
There is no Char type. A single character is a String of length 1.
Variables — val / var
val name = "Victor" -- immutable, type inferred var hp = 100 -- mutable, type inferred val speed: Float = 200.0 -- immutable, type explicit name = "Other" -- COMPILE ERROR: val cannot be reassigned hp = 90 -- ok
val= immutable bindingvar= mutable binding- Type is inferred by default, can be explicit with
: Type
Nullable Types
Non-nullable by default. Opt in with ?.
val name: String = "Victor" name = null -- COMPILE ERROR: String is not nullable var target: Enemy? = null -- explicitly nullable target.attack() -- COMPILE ERROR: must check first if target != null { target.attack() -- ok, compiler narrows type }
Strings
Strings are immutable reference types. Passed by reference (no copy of character data), but characters can never be modified — operations always create new strings.
val greeting = "Hello World" -- length, iteration print(len(greeting)) for c in greeting { print(c) } -- string interpolation val name = "Victor" val hp = 100 print("{name} has {hp} HP") -- multiline val text = """ multi line string """ -- common operations val upper = greeting.upper() val sub = greeting[0..5] val has = greeting.contains("World") val parts = greeting.split(" ")
Passing Semantics
Strings are passed by reference — no character data is copied. var rebinds the reference, it does not mutate the characters.
Compiler Optimizations
- Interning: identical string literals share the same memory at compile time
- Small string optimization: short strings stored inline, no heap allocation
- Zero-copy passing: only the reference struct is copied (pointer + length), never the characters
Objects
Declared with the object keyword. In v3, construction goes through init bodies instead of field declaration order.
object Player { var pos: Vec2 var hp: Int val speed: Float val name: String init(pos: Vec2, hp: Int, speed: Float, name: String) { self.pos = pos self.hp = hp self.speed = speed self.name = name } init hero(name: String) { self.pos = vec2(100.0, 100.0) self.hp = 100 self.speed = 200.0 self.name = name } } val a = Player(vec2(0.0, 0.0), 100, 200.0, "Scout") val b = Player.hero("Victor")
Inheritance — extends
Single inheritance only. Child objects inherit all fields and methods from the parent.
object Entity { var pos: Vec2 var hp: Int init(pos: Vec2, hp: Int) { self.pos = pos self.hp = hp } func take_damage(amount: Int) { self.hp = self.hp - amount } } object Player extends Entity { val armor: Int init(pos: Vec2, hp: Int, armor: Int) { self.pos = pos self.hp = hp self.armor = armor } func take_damage(amount: Int) { val reduced = amount - self.armor super.take_damage(reduced) } }
Interfaces & Static Dispatch
Interfaces define behavioral contracts. The compiler resolves all dispatch statically — no vtables, no function pointers. Interface-typed variables become tagged unions, and method calls become a match on the tag.
Declaring Interfaces
interface Entity { func update(dt: Float) readonly func draw() } object Player implements Entity { var pos: Vec2 var vel: Vec2 init(pos: Vec2, vel: Vec2) { self.pos = pos self.vel = vel } func update(dt: Float) { self.pos.x += self.vel.x * dt } readonly func draw() { draw_rect(self.pos, 32, 32) } } object Enemy implements Entity { var pos: Vec2 var hp: Int init(pos: Vec2, hp: Int) { self.pos = pos self.hp = hp } func update(dt: Float) { self.pos.x -= dt } readonly func draw() { draw_rect(self.pos, 32, 32) } }
Interface-typed Variables
Assign any implementing type to an interface variable. The compiler automatically wraps it in a tagged union.
-- concrete type auto-wraps into the interface union val e: Entity = Player(vec2(0,0), vec2(1,0)) e.update(0.016) -- dispatches via tag match, not vtable -- pass concrete types where interface is expected func process(e: Entity) { e.update(0.016) } process(Enemy(vec2(5,3), 100)) -- auto-wrapped
How It Works (Under the Hood)
The compiler performs whole-program analysis to find all implementors of each interface, then generates:
- A tagged union with a discriminant tag per concrete type
- A dispatch function per method that switches on the tag
- Dead implementor elimination — unused types are pruned from the union
// What the compiler generates (C output): typedef enum { Iron_Entity_TAG_Enemy = 0, Iron_Entity_TAG_Player = 1 } Iron_Entity_Tag; typedef union { Iron_Enemy Enemy; Iron_Player Player; } Iron_Entity_data_t; struct Iron_Entity { Iron_Entity_Tag tag; Iron_Entity_data_t data; }; // Dispatch: switch on tag, not function pointer static inline void Iron_entity_update(Iron_Entity self, double dt) { switch(self.tag) { case Iron_Entity_TAG_Player: Iron_player_update(self.data.Player, dt); break; case Iron_Entity_TAG_Enemy: Iron_enemy_update(self.data.Enemy, dt); break; } }
Collection Splitting
Interface-typed arrays are automatically split into per-type homogeneous sub-arrays. This delivers ECS-level cache performance from polymorphic code:
-- You write this: val entities = [Player(pos, health), Enemy(pos, hp)] for e in entities { e.update(0.016) } -- Compiler generates per-type loops with prefetch: // Players: tight loop over contiguous Player[] // for (_sp_i..player_count) Iron_player_update(players[i]) // Enemies: tight loop over contiguous Enemy[] // for (_sp_i..enemy_count) Iron_enemy_update(enemies[i])
Hardware Optimization
Split loops receive hardware hints automatically:
- Prefetch insertion —
__builtin_prefetchwarms the next cache line ahead of iteration - SIMD-friendly layout — per-type contiguous arrays are ideal for auto-vectorization by the C backend
Enums
Simple C-style enums, always accessed with the type prefix. Enum variants can also carry data payloads, making them full algebraic data types.
Plain Enums
enum GameState { PAUSED, RUNNING, MENU, } var state = GameState.RUNNING
Algebraic Data Types
Variants can carry typed payloads.
enum Shape { Circle(Float), Rect(Float, Float), Point, } val s = Shape.Circle(5.0) val r = Shape.Rect(10.0, 20.0)
Generic Enums
Enums support type parameters with monomorphization.
enum Option[T] { Some(T), None, } enum Result[T, E] { Ok(T), Err(E), }
Methods on Enums
Declare enum methods inside the enum block and use match self to dispatch on variants.
enum Shape { Circle(Float), Rect(Float, Float), Point, readonly func area() -> Float { match self { Shape.Circle(r) -> return 3.14159 * r * r Shape.Rect(w, h) -> return w * h Shape.Point -> return 0.0 } } } val s = Shape.Circle(5.0) println("area: {s.area()}")
Recursive Enums
Self-referencing variants are automatically heap-allocated (auto-boxed).
enum Expr { IntLit(Int), BinOp(Expr, Op, Expr), }
Generics
Full generics using [T] syntax. Works on functions, objects, and methods. Uses monomorphization.
-- generic function func find[T](items: [T], check: func(T) -> Bool) -> T? { for item in items { if check(item) { return item } } return null } -- generic object with in-block methods object Pool[T] { var items: [T] var count: Int init(items: [T], count: Int) { self.items = items self.count = count } func get() -> T? { if self.count > 0 { self.count -= 1 return self.items[self.count] } return null } } -- usage var bullet_pool = Pool[Bullet](heap [Bullet; 256], 0) val b = bullet_pool.get()
Functions & Methods
All declared with the func keyword.
Standalone Functions
func add(a: Float, b: Float) -> Float { return a + b }
Methods
In v3, instance methods live inside the object block. Use plain func for mutating methods, readonly func for read-only methods, and pure func when the compiler should enforce no writes and no I/O.
object Player { var hp: Int init(hp: Int) { self.hp = hp } func take_damage(amount: Int) { self.hp = self.hp - amount } readonly func is_alive() -> Bool { return self.hp > 0 } pure func clamped_hp() -> Int { return max(0, self.hp) } }
Patch — Open Extension
patch object T { ... } adds methods and named inits to types you do not own, including built-in primitives. Patches are program-wide and may not add fields.
patch object Int { pub readonly func double() -> Int { return self * 2 } } func main() { println("{5.double()}") }
Passing Convention
Positional arguments only. Primitives are always copied. Objects and Strings are passed by reference, immutable by default. Use var to allow mutation.
-- immutable reference (default) func print_stats(player: Player) { print("{player.name}: {player.hp}") player.hp = 0 -- COMPILE ERROR: immutable } -- mutable reference func heal(var player: Player, amount: Int) { player.hp += amount -- ok, modifies original }
Tuple Returns
Functions can return tuples using parenthesised type lists. Call sites destructure with val (a, b) = f(). Tuples are first-class, can contain any mix of primitives and objects, and are how the stdlib reports (value, error) pairs.
func divide(a: Float, b: Float) -> (Float, NetError) { if b == 0.0 { return (0.0, NetError(1)) } return (a / b, NetError(0)) } val (result, err) = divide(10.0, 0.0) if err.code != 0 { println("error {err.code}") } -- mixed heterogeneous tuples work too func describe() -> (Int, String) { return (42, "hello") } val (n, s) = describe()
The codegen lays tuples out as contiguous structs, so calling Net.tcp_dial("host", 80, 2000) returns a (TcpSocket, NetError) pair in a single allocation — no boxing, no heap traffic on the happy path.
Lambdas
Anonymous functions use func() { } — same keyword, just without a name. Outer val bindings are captured by value, var bindings by reference.
val double = func(x: Int) -> Int { x * 2 } val on_collision = func(entity: Entity, other: Entity) { entity.take_damage(10) spawn_particles(entity.pos) } -- inferred types from context val enemy = find[Enemy](enemies, func(e) { e.hp < 50 }) -- lambdas as types in objects object Button { val label: String val on_click: func() } val btn = Button("Start", func() { start_game() })
Control Flow
Brace-delimited blocks with familiar syntax.
if player.hp <= 0 { player.die() } elif player.hp < 20 { player.warn_low_hp() } else { player.update(dt) } for i in range(len(bullets)) { bullets[i].update(dt) } for c in name { print(c) } while not window_should_close() { val dt = get_frame_time() }
Pattern Matching
Use -> arrow syntax for match arms. The compiler enforces exhaustiveness — all variants must be covered or an else arm provided.
match state { GameState.RUNNING -> player.update(dt) GameState.PAUSED -> draw_pause_menu() GameState.MENU -> draw_main_menu() } -- destructuring payloads match shape { Shape.Circle(radius) -> { val area = 3.14159 * radius * radius println("circle area: {area}") } Shape.Rect(w, h) -> println("rect area: {w * h}") Shape.Point -> println("point") } -- wildcard _ ignores a binding match shape { Shape.Rect(_, h) -> println("height: {h}") else -> println("not a rect") }
Memory Management
Iron gives you explicit control over memory with compiler-assisted safety. There is no garbage collector and no borrow checker. Instead, the compiler runs a dedicated escape-analysis pass that classifies every heap allocation and inserts free calls for you when it can prove they are safe. When it can't, it tells you — with a specific error code — so you can choose what to do.
The Four Allocation Strategies
Every value in an Iron program lives in exactly one of four places. You pick the strategy at the allocation site; everything else is enforced by the compiler.
| Strategy | Keyword | Lifetime | Use when… |
|---|---|---|---|
| Stack | (default) | Freed at end of declaring block | Value never leaves its scope |
| Heap, auto-freed | heap | Freed at end of declaring block | Value is large, but still scope-local |
| Heap, manual | heap + free/leak | You control it | Value escapes its scope |
| Reference counted | rc | Freed when last reference dies | Value has shared ownership |
defer is not an allocation strategy — it is a cleanup scheduler that works with any of the four to release non-memory resources like files, sockets, or windows.
Stack — Default
No keyword. Values are laid out on the stack of the function that declares them and freed when their declaring block exits. This is the fastest allocation path and the one you should reach for first.
val pos = vec2(10.0, 20.0) -- 16 bytes on the stack val health = 100 -- 8 bytes on the stack val inventory: [Item; 8] -- 8 items, inline on the stack func distance(a: Vec2, b: Vec2) -> Float { val dx = a.x - b.x -- stack val dy = a.y - b.y -- stack return sqrt(dx * dx + dy * dy) }
Stack values cannot escape their declaring block. Attempting to return one or store it somewhere that outlives the current scope is a compile error. If a value needs to outlive its block, allocate it with heap.
-- COMPILE ERROR: stack value cannot escape func make_grid() -> [Vec2; 16] { var grid: [Vec2; 16] return grid -- ERROR: stack escape } -- OK: escape to heap func make_grid() -> [Vec2; 16] { val grid = heap [Vec2; 16] return grid -- ok: caller owns it } -- OK: build in caller's scope, fill via parameter var grid: [Vec2; 16] fill_grid(grid)
Heap — heap
Prefix any constructor or array literal with heap to allocate it dynamically.
-- single object val boss = heap Enemy(Vec2(0.0, 0.0), 1000) -- fixed-size array val enemies = heap [Enemy; 64] -- call that returns a heap value val atlas = heap load_texture("atlas.png") -- large buffer for scratch work val scratch = heap [UInt8; 65536]
heap on its own doesn't decide when the allocation is freed — that depends on whether the value escapes its declaring block. The compiler figures that out for you.
Auto-Free — Block-Scoped
Heap values that do not escape their declaring block are freed automatically at block exit. You do not write a defer free for them; the compiler emits the free for you.
func parse_packet(bytes: [UInt8]) { val buf = heap [UInt8; 4096] decode_into(buf, bytes) log_packet(buf) -- buf auto-freed here, end of function block }
Auto-free respects any block, not just function bodies. A heap allocation inside an if, while, or for body is freed at the end of that body — which means loop bodies free and reallocate each iteration instead of accumulating:
func main() { -- reallocated and auto-freed every iteration for i in range(5) { val d = heap Data(i, "item") println("data: {d.value}") -- d auto-freed here } -- conditional: only allocated if the branch runs for i in range(6) { if i > 2 { val p = heap Point(i, i * 2) println("point: {p.x},{p.y}") -- p auto-freed here, end of if body } } -- nested: innermost block owns it for a in range(3) { for b in range(3) { if a == b { val d = heap Data(a + b, "diag") println("diag: {d.value}") -- freed at end of innermost if } } } }
Mental model: auto-free makes heap feel almost like a stack allocation with no size limit. Reach for it whenever a value is too large to stack-allocate but is still scope-local — decoding buffers, temporary work arrays, short-lived objects.
Escape Analysis — What "Escape" Means
A heap value "escapes" when the compiler determines it might outlive its declaring block. Iron's analysis is intra-procedural (per-function) and conservative — it errs on the side of flagging things that might escape, never the reverse. A heap binding is marked as escaping if any of the following happens inside the function that declared it:
| Situation | Escapes? |
|---|---|
return the value from the function | Yes |
| Assign it to a variable (may target outer scope) | Yes |
| Pass it as a function argument | Yes — the callee might keep a reference |
| Pass it as a method-call argument | Yes — same reason |
| Read its fields, index into it, print it | No |
Call a method on it (x.do()) | No — the receiver is not an argument |
| Access its fields to compute another value | No |
-- #1 return escape func create_boss() -> Enemy { val boss = heap Enemy(Vec2(0.0, 0.0), 1000) return boss -- E0207: escapes without 'free' or 'leak' } -- #2 assignment escape func swap_in(var slot: Enemy?) { val e = heap Enemy(Vec2(0.0, 0.0), 100) slot = e -- E0207: escapes via assignment } -- #3 function-argument escape (conservative) func enqueue() { val e = heap Enemy(Vec2(0.0, 0.0), 100) register(e) -- E0207: callee might retain 'e' } -- NO escape: method call on the value is fine func use_locally() { val e = heap Enemy(Vec2(0.0, 0.0), 100) e.take_damage(10) -- ok println("hp: {e.hp}") -- ok -- e auto-freed here }
When the compiler detects an escape without a matching free or leak, it emits E0207:
error[E0207]: heap value 'boss' escapes its declaring scope
without 'free' or 'leak'; possible memory leakThe fix is one of three decisions: free it before the scope ends, mark it leak, or switch to rc if the value is genuinely shared.
Explicit free
free releases a heap allocation at a specific point instead of waiting for auto-free. Use it when a value is escaping and you want to manage its lifetime yourself, or when you want to release a large buffer early inside a long block.
func run_level() { val boss = heap Enemy(Vec2(0.0, 0.0), 1000) fight(boss) free boss -- explicit cleanup -- lots more work below doesn't hold onto 'boss' memory run_puzzles() run_minigame() }
Combining free with escape — silencing E0207 by releasing the value on the last path that uses it:
func compute_checksum(path: String) -> UInt32 { val buf = heap [UInt8; 1 << 20] -- 1 MiB scratch val n = read_file_into(path, buf) val sum = crc32(buf, n) free buf -- release before return return sum }
Calling free on a value the compiler doesn't recognize as a heap binding is a compile error:
val pos = vec2(10.0, 20.0) free pos -- E0212: 'free' on 'pos': not a heap-allocated value
leak — Intentional Permanent Allocation
leak says: yes, this allocation escapes; yes, it lives until the process ends; that's the design. It silences E0207 and documents intent at the allocation site.
-- texture atlas loaded once, used for the whole program val atlas = heap load_texture("atlas.png") leak atlas -- precomputed lookup table, never freed val sin_table = heap [Float; 360] precompute_sin(sin_table) leak sin_table -- global config loaded from disk at startup val config = heap load_config("settings.ini") leak config
Leaked values are still usable after leak — the keyword only affects the lifetime policy. It is not a hint to delete the variable or drop its fields.
leak refuses to apply where it would be misleading:
val count = 5 leak count -- E0213: not a heap-allocated value val tex = rc load_texture("hero.png") leak tex -- E0214: rc values manage their own lifetime
Reference Counting — rc
rc wraps a heap allocation in a reference-counted handle. Copying the variable increments the count; dropping a handle decrements it. When the last handle goes away the value is freed. Use rc when ownership is genuinely shared — the same texture referenced by many sprites, a config shared by every subsystem, a graph where several nodes point at the same child.
val sprite = rc load_texture("hero.png") -- ref count = 1 var other = sprite -- ref count = 2 other = null -- ref count = 1 -- when the last handle drops, the texture is freed
rc values are exempt from escape analysis: they already have well-defined shared lifetime, so returning them, storing them in objects, and passing them to functions all compile without E0207 and without free/leak.
object Config { var name: String var value: Int } func load_shared_config() -> rc Config { return rc Config("app", 42) -- ok, no warning } func main() { val cfg = load_shared_config() init_audio(cfg) init_video(cfg) init_net(cfg) -- cfg dropped here, ref count hits 0, Config freed }
Using rc in an object field declares that the field participates in shared ownership:
object Sprite { var pos: Vec2 val texture: rc Texture -- shared across many sprites } val hero_tex = rc load_texture("hero.png") val a = Sprite(vec2(0.0, 0.0), hero_tex) val b = Sprite(vec2(50.0, 0.0), hero_tex) -- texture freed only when a, b, and hero_tex are all gone
Defer — Scheduled Cleanup
defer schedules a statement to run at scope exit, in LIFO order, from every exit path including early returns. It is the tool for deterministic cleanup of resources that aren't just memory — file handles, window handles, sockets, mutex unlocks, transactions.
func main() { defer println("3") defer println("2") defer println("1") } -- prints: 1, 2, 3 (LIFO)
Defers run on every path out of their declaring scope — normal return, early return, or fall-through — and each defer only fires if control actually reached its declaration:
func process(x: Int) -> String { defer println("cleanup A") if x < 0 { return "negative" -- runs: cleanup A } defer println("cleanup B") if x == 0 { return "zero" -- runs: cleanup B, cleanup A } defer println("cleanup C") return "positive" -- runs: cleanup C, cleanup B, cleanup A }
Typical resource patterns:
func run_game() { val window = init_window(800, 600, "Game") defer close_window(window) val save = open_file("save.dat") defer close_file(save) val audio = init_audio() defer shutdown_audio(audio) main_loop(window, save, audio) -- at end of function, defers fire in reverse order: -- shutdown_audio, close_file, close_window }
defer works at any block level. A defer inside a loop body fires at the end of each iteration:
for path in files { val f = open_file(path) defer close_file(f) -- fires at end of each iteration process_file(f) }
Heap memory usually doesn't need defer. Auto-free already handles non-escaping heap allocations, and free is more explicit when you need it. Reach for defer for non-memory resources, or when symmetry with init_x / close_x pairs makes the code clearer.
Compiler Diagnostics
Every memory-related error has a stable code. You can search for these in your terminal output or in the compiler source to understand exactly what triggered them.
| Code | Trigger | Severity |
|---|---|---|
E0207 | heap value escapes its declaring block without a matching free or leak | Error |
E0212 | free applied to a value that is not a heap binding | Error |
E0213 | leak applied to a value that is not a heap binding | Error |
E0214 | leak applied to an rc value (rc manages its own lifetime) | Error |
Summary of what the compiler does with each combination:
| Situation | Outcome |
|---|---|
heap, value does not escape | Auto-freed at block exit |
heap + free, regardless of escape | Freed at the free site, no warning |
heap + leak, value escapes | Never freed; no warning (intentional) |
heap, value escapes, no free/leak | E0207 |
free on a non-heap value | E0212 |
leak on a non-heap value | E0213 |
leak on an rc value | E0214 |
rc value anywhere | Exempt from escape analysis |
Choosing a Strategy
When you are about to write an allocation, ask these questions in order:
- Can this live on the stack? It's small, its lifetime is the current block, and you never need to return it — just declare it with
val/var. Stop. - Is it too large for the stack, but still scope-local? Prefix it with
heap. Auto-free handles it. - Does it need to escape the block (return, store, pass)? Use
heapand decide who owns it:- If the caller owns it, return it and
freeit at the caller side. - If it lives for the whole program, use
leak. - If ownership is genuinely shared, use
rc.
- If the caller owns it, return it and
- Is it a non-memory resource (file, window, socket, lock)? Use
deferright after acquiring it.
Default to stack. Promote to heap when you must. Only use rc for real shared ownership. Reference counting is cheap but not free — every copy and drop costs an atomic increment or decrement.
No Pointers
There are no pointer types in Iron. You never write *T or &x, and there is no dereference operator. The compiler decides when to lower a value to a C pointer based on mutability and type:
| Iron | Generated C |
|---|---|
player: Player | const Player *player |
var player: Player | Player *player |
x: Int | int64_t x (copied) |
data: [UInt8; 64] | const uint8_t *data |
heap Enemy(…) | malloc-backed, freed by the compiler, free, or scope exit |
rc Texture(…) | Heap-backed handle with refcount header |
The consequence is that you can't hand-craft unusual aliasing patterns — but you also can't write dangling-pointer bugs or forget to dereference. If you need raw pointer interop for FFI, that happens inside the C backend, not in Iron source.
Concurrency
Thread pools, spawn/await, typed channels, mutexes, and parallel loops are all first-class language features.
Thread Pools
val compute = pool("compute", 4) val io = pool("io", 1) val physics = pool("physics", 2) -- pin to specific CPU cores physics.pin(2, 3)
Spawn & Await
-- spawn on default pool spawn("autosave") { save_game(state) } -- spawn on a specific pool, get a handle val handle = spawn("asset-loader", io) { return load_texture("hero.png") } val tex = await handle -- block until done -- non-blocking check if handle.done() { val tex = handle.result() }
Channels
Typed, optionally buffered.
val ch = channel[String]() -- unbuffered val ch = channel[Texture](4) -- buffered (capacity 4) spawn("loader", io) { ch.send(load_texture("hero.png")) } val tex = ch.recv() -- blocking val tex = ch.try_recv() -- non-blocking (T?) ch.close()
Mutex
val score = mutex(0) spawn("scorer") { score.lock(func(var s) { s += 10 }) } score.lock(func(val s) { print("score: {s}") })
Parallel For
Add parallel to split a loop across cores. Each iteration must be independent. An implicit barrier ensures completion.
-- parallel on default pool for i in range(len(particles)) parallel { particles[i].update(dt) } -- parallel on a specific pool for i in range(len(particles)) parallel(compute) { particles[i].update(dt) } -- COMPILE ERROR: can't mutate outer var in parallel for var total = 0 for i in range(len(enemies)) parallel { total += enemies[i].hp -- ERROR }
Modules
One file = one module. No module declaration needed. The file path is the module name.
my_game/ main.iron -- entry point player.iron -- module: player enemy.iron -- module: enemy physics/ collision.iron -- module: physics.collision rigid_body.iron -- module: physics.rigid_body
-- flat import: everything available directly import player -- aliased import: prefixed access import physics.rigid_body as rb val body = rb.create_body(pos, mass)
Visibility
Everything is private by default in v3. Add pub to expose an object, field, method, or init across module boundaries. The old public-by-default private model was removed.
pub object Player { var health: Int pub var name: String pub init(health: Int, name: String) { self.health = health self.name = name } pub func take_damage(n: Int) { self.health = self.health - n } func recalc() { self.health = clamp(self.health, 0, 100) } }
Compile-Time Evaluation
Any pure function can be evaluated at compile time using comptime at the call site. The result is baked into the binary with zero runtime cost.
func build_sin_table() -> [Float; 360] { var table = [Float; 360] for i in range(360) { table[i] = sin(Float(i) * 3.14159 / 180.0) } return table } -- comptime at call site val SIN_TABLE = comptime build_sin_table() -- embed files at compile time val SHADER_SOURCE = comptime read_file("shaders/main.glsl") -- same function works at runtime too val dynamic_table = build_sin_table()
Restrictions
Can use: math, loops, conditionals, file reads (embed assets), string manipulation.
Cannot use: heap, free, rc, runtime APIs (raylib, OS, network).
Managed Blocks
Managed blocks that auto-wrap begin/end pairs. The draw block automatically calls BeginDrawing()/EndDrawing().
draw { clear(DARKGRAY) player.draw() for i in range(bullet_count) { draw_circle(bullets[i].pos, 4.0, RED) } draw_text("{player.name}: {bullet_count} bullets", 10, 10, 20, WHITE) }
Standard Library
Built-in — No Import Needed
-- printing print("hello") println("hello") -- type conversions val f = Float(42) val i = Int(3.14) val s = String(100) -- common functions len(array) range(end) range(start, end) abs(x) min(a, b) max(a, b) clamp(val, min, max) assert(condition)
Collections
-- List: dynamic array var enemies = List[Enemy]() enemies.add(enemy) enemies.remove(0) val e = enemies.get(0) -- Map: key-value store var scores = Map[String, Int]() scores.set("victor", 100) val s = scores.get("victor") -- Int? -- Set: unique values var tags = Set[String]() tags.add("enemy") val has = tags.has("enemy")
math Module
import math math.PI math.TAU math.E math.sin(x) math.cos(x) math.tan(x) math.asin(x) math.acos(x) math.atan2(y, x) math.floor(x) math.ceil(x) math.round(x) math.sqrt(x) math.pow(b, e) math.lerp(a, b, t) math.random() -- 0.0..1.0 math.random_int(min, max) math.random_float(min, max)
io Module
import io val data, val err = io.read_file("save.dat") val err = io.write_file("save.dat", data) val exists = io.file_exists("save.dat") val files = io.list_files("assets/") io.create_dir("saves/") io.delete_file("temp.dat")
time Module
import time val now = time.now() -- seconds since start val ms = time.now_ms() -- milliseconds time.sleep(0.5) var timer = time.Timer(2.0) timer.update(dt) if timer.done() { spawn_wave() timer.reset() }
log Module
import log log.info("game started") log.warn("low memory") log.error("failed to load texture") log.debug("player pos: {player.pos}") log.set_level(log.WARN)
net Module
TCP client/server, UDP, DNS, and typed IP addresses. Every fallible call returns a (value, NetError) tuple — the error is always a concrete value, never an exception. All timeouts are integer milliseconds.
TCP Client & Server
import net -- dial a TCP peer with a 2-second budget val (sock, err) = Net.tcp_dial("example.com", 80, 2000) if err.code != 0 { println("dial failed: {err.code}") return } defer TcpSocket.close(sock) val (nw, werr) = TcpSocket.write(sock, "GET / HTTP/1.0\r\n\r\n", 1000) -- listen on all interfaces (dual-stack when host is "::") val (listener, lerr) = Net.tcp_listen("127.0.0.1", 34503) defer TcpListener.close(listener) val (peer, aerr) = TcpListener.accept(listener, 1000) if aerr.code == 0 { TcpSocket.close(peer) }
UDP
-- bind an ephemeral port (0 asks the OS to pick) val (udp, uerr) = Net.udp_bind("0.0.0.0", 0) defer UdpSocket.close(udp) val (dst, _) = IPv4Addr.parse("127.0.0.1") val (sent, serr) = Net.udp_sendto_v4(udp, "ping", dst, 5353, 1000)
IP Addresses
IPv4 and IPv6 addresses are typed values with parse / format helpers. The Address enum is the tagged union DNS lookups return.
val (v4, _) = IPv4Addr.parse("10.0.0.1") println(IPv4Addr.format(v4)) val (v6, _) = IPv6Addr.parse("2001:db8::1") -- Address ADT: Net.lookup_host returns [Address] enum Address { V4(IPv4Addr), V6(IPv6Addr), }
DNS
Asynchronous hostname resolution backed by an elastic thread pool. If a worker hangs on getaddrinfo the caller still returns promptly on its own deadline with IRON_ERR_NET_TIMEOUT — the pool grows a replacement worker on demand.
val (addrs, err) = Net.lookup_host("example.com", 2000) if err.code != 0 { println("lookup failed") return } for a in addrs { match a { Address.V4(v4) -> println(IPv4Addr.format(v4)) Address.V6(v6) -> println(IPv6Addr.format(v6)) } }
url Module
RFC 3986 URL parser, builder, resolver, and percent codec — implemented in pure Iron with no C backing. Fallible calls return (value, UrlError) tuples in the same shape as the net module.
import url -- parse an absolute URL val (u, e) = Url.parse("https://victor@example.com:8443/docs?q=iron#top") if e.code == 0 { println(u.scheme) -- "https" println(u.host) -- "example.com" println(u.port) -- 8443 println(u.path) -- "/docs" println(u.query) -- "q=iron" println(u.fragment) -- "top" } -- RFC 3986 §5 reference resolution val (base, _) = Url.parse("https://example.com/a/b/c") val (abs, _) = Url.resolve(base, "../d") println(Url.build(abs)) -- "https://example.com/a/d" -- fluent builder val s = Url.builder() .set_scheme("https") .set_host("api.example.com") .set_path("/v1/ping") .build() -- percent codec val enc = Url.percent_encode("hello world", false) val (dec, _) = Url.percent_decode("hello%20world") -- scheme default ports println(Url.default_port("https")) -- 443
Comments
-- single line commentKeywords Summary
| Keyword | Purpose |
|---|---|
val | Immutable binding |
var | Mutable binding |
func | Function / method declaration |
object | Data structure declaration |
init | Constructor declaration inside an object |
enum | Enumeration declaration |
interface | Interface declaration |
pub | Opt-in cross-module visibility |
readonly | Method modifier: no field writes |
pure | Method modifier: no field writes, no I/O |
import | Module import |
if / elif / else | Conditional branching |
for / while | Loops |
match | Pattern matching with -> arrow syntax |
return | Return from function |
heap | Heap allocation |
free | Heap deallocation |
leak | Intentional permanent allocation |
defer | Execute at scope exit |
rc | Reference-counted wrapper |
patch | Open extension for existing types |
extends | Single inheritance |
implements | Interface implementation (static dispatch) |
super | Call parent method |
is | Runtime type check |
spawn | Launch a thread |
await | Wait for thread result |
parallel | Parallel for loop modifier |
pool | Create a thread pool |
comptime | Compile-time evaluation |
self | Implicit method receiver |
not / and / or | Logical operators |
true / false / null | Literal values |
Full Example
A complete game with player movement, bullets, and rendering.
import raylib import math as m object Player { var pos: Vec2 var hp: Int val speed: Float val name: String val sprite: rc Texture init hero(name: String) { self.pos = vec2(100.0, 100.0) self.hp = 100 self.speed = 200.0 self.name = name self.sprite = rc load_texture("hero.png") } func update(dt: Float) { if is_key_down(.RIGHT) { self.pos.x += self.speed * dt } if is_key_down(.LEFT) { self.pos.x -= self.speed * dt } if is_key_down(.UP) { self.pos.y -= self.speed * dt } if is_key_down(.DOWN) { self.pos.y += self.speed * dt } } readonly func draw() { draw_texture(self.sprite, self.pos) } readonly func is_alive() -> Bool { return self.hp > 0 } } func main() { val window = init_window(800, 600, "My Game") defer close_window(window) var player = Player.hero("Victor") val bullets = heap [Bullet; 256] var bullet_count = 0 while not window_should_close() { val dt = get_frame_time() player.update(dt) if is_key_pressed(.SPACE) { bullets[bullet_count] = player.shoot() bullet_count += 1 } for i in range(bullet_count) { bullets[i].update(dt) } draw { clear(DARKGRAY) player.draw() for i in range(bullet_count) { if bullets[i].alive { draw_circle(bullets[i].pos, 4.0, RED) } } draw_text("{player.name}: {bullet_count} bullets", 10, 10, 20, WHITE) } } }