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.

Philosophy

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

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

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:

// 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:

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.

StrategyKeywordLifetimeUse when…
Stack(default)Freed at end of declaring blockValue never leaves its scope
Heap, auto-freedheapFreed at end of declaring blockValue is large, but still scope-local
Heap, manualheap + free/leakYou control itValue escapes its scope
Reference countedrcFreed when last reference diesValue 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:

SituationEscapes?
return the value from the functionYes
Assign it to a variable (may target outer scope)Yes
Pass it as a function argumentYes — the callee might keep a reference
Pass it as a method-call argumentYes — same reason
Read its fields, index into it, print itNo
Call a method on it (x.do())No — the receiver is not an argument
Access its fields to compute another valueNo
-- #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 leak

The 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.

CodeTriggerSeverity
E0207heap value escapes its declaring block without a matching free or leakError
E0212free applied to a value that is not a heap bindingError
E0213leak applied to a value that is not a heap bindingError
E0214leak applied to an rc value (rc manages its own lifetime)Error

Summary of what the compiler does with each combination:

SituationOutcome
heap, value does not escapeAuto-freed at block exit
heap + free, regardless of escapeFreed at the free site, no warning
heap + leak, value escapesNever freed; no warning (intentional)
heap, value escapes, no free/leakE0207
free on a non-heap valueE0212
leak on a non-heap valueE0213
leak on an rc valueE0214
rc value anywhereExempt from escape analysis

Choosing a Strategy

When you are about to write an allocation, ask these questions in order:

  1. 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.
  2. Is it too large for the stack, but still scope-local? Prefix it with heap. Auto-free handles it.
  3. Does it need to escape the block (return, store, pass)? Use heap and decide who owns it:
    • If the caller owns it, return it and free it at the caller side.
    • If it lives for the whole program, use leak.
    • If ownership is genuinely shared, use rc.
  4. Is it a non-memory resource (file, window, socket, lock)? Use defer right 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:

IronGenerated C
player: Playerconst Player *player
var player: PlayerPlayer *player
x: Intint64_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 comment

Keywords Summary

KeywordPurpose
valImmutable binding
varMutable binding
funcFunction / method declaration
objectData structure declaration
initConstructor declaration inside an object
enumEnumeration declaration
interfaceInterface declaration
pubOpt-in cross-module visibility
readonlyMethod modifier: no field writes
pureMethod modifier: no field writes, no I/O
importModule import
if / elif / elseConditional branching
for / whileLoops
matchPattern matching with -> arrow syntax
returnReturn from function
heapHeap allocation
freeHeap deallocation
leakIntentional permanent allocation
deferExecute at scope exit
rcReference-counted wrapper
patchOpen extension for existing types
extendsSingle inheritance
implementsInterface implementation (static dispatch)
superCall parent method
isRuntime type check
spawnLaunch a thread
awaitWait for thread result
parallelParallel for loop modifier
poolCreate a thread pool
comptimeCompile-time evaluation
selfImplicit method receiver
not / and / orLogical operators
true / false / nullLiteral 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)
    }
  }
}