Attributes & Lifetimes

Module attributes and the three-tier lifetime system in Chasm, frame, script, and persistent arenas.

Module attributes are module-level variables that survive across function calls. They are the primary way to store game state, score, player position, entity lists, loaded asset handles.

What is a Lifetime?

A lifetime tells the compiler (and the runtime) how long a value is allowed to live. Chasm has three:

frame  <  script  <  persistent

Each lifetime corresponds to an arena, a region of pre-allocated memory. When a lifetime expires, its arena is reset in a single pointer operation: no garbage collection, no per-element work, no hidden cost.

LifetimeArena reset whenTypical use
frameEvery tick (start of each on_tick)Per-frame scratch: temp lists, movement deltas
scriptOn hot-reloadPlayer state, score, active entities
persistentNever (process exit only)High score, loaded fonts, config

Declaring Attributes

@score      :: script     = 0
@high_score :: persistent = 0
@speed      :: script     = 400.0
@temp       :: frame      = 0.0

The syntax is:

@name :: lifetime = initial_value
  • @name, the attribute name. The @ prefix appears everywhere it is used, including reads and writes.
  • :: lifetime, one of frame, script, or persistent.
  • = initial_value, evaluated once when the module is first loaded.

The frame Arena

The frame arena is the cheapest memory in Chasm. It resets at the start of every on_tick call by moving a single pointer, no loop, no GC, zero overhead.

@candidates :: frame = array_fixed(64, 0)

def on_tick(dt :: float) do
  # @candidates starts empty every tick, no manual .clear() needed
  for i in 0..@all_enemies.len do
    e = @all_enemies.get(i)
    if in_range(e, @player) do
      @candidates.push(i)
    end
  end
  # process @candidates...
end

Frame attrs are a free scratch pad. Use them liberally for intermediate results that you do not need after the tick ends.

Frame attrs reset unconditionally:

You cannot "save" a frame value by assigning it to a frame attr, it resets regardless of what you wrote. Frame lifetime means "this tick only".

The script Arena

Script attrs survive between ticks but reset on hot-reload. This is the right home for active game state:

@score   :: script = 0
@lives   :: script = 3
@player  :: script = Player{}
@enemies :: script = array_fixed(32, Enemy{})

When you hot-reload after fixing a bug, all script attrs reinitialize from their declared defaults. This makes iteration fast, you always start from a clean slate.

The persistent Arena

Persistent attrs never reset. Use them for things that must survive hot-reload:

@high_score :: persistent = 0
@font       :: persistent = 0   # handle set in on_init

def on_init() do
  @font = load_font("assets/mono.ttf")
end

Loading a font in on_init is the common pattern: @font is persistent so it is not lost when you reload the script. on_init only runs once per process start, not on every reload.

Reading and Writing Attributes

Read @name anywhere you would use a variable:

speed = @speed * 2.0
msg   = "Score: #{@score}"

Write @name as a standalone statement:

@score = @score + 10
@player = @player with { x: @player.x + dx }

Attribute assignment is a statement, it cannot appear inside an expression:

# Error: cannot nest @attr assignment
x = (@score = 5) + 1

# Correct: two statements
@score = 5
x = @score + 1

Lifetime Safety, E008

The compiler enforces that values only flow upward along the lifetime hierarchy. Assigning a shorter-lived value to a longer-lived attribute is a compile error (E008):

@saved :: script = 0

def on_tick(dt :: float) do
  local = compute()    # local has frame lifetime
  @saved = local       # E008: lifetime violation
                       # frame value cannot be stored in script attr
end

The error message tells you the lifetimes involved and suggests the fix.

Promotion Functions

To move a value into a longer-lived arena, use an explicit promotion call:

@saved :: script     = 0
@best  :: persistent = 0

def on_tick(dt :: float) do
  result = compute()                  # frame lifetime

  @saved = copy_to_script(result)     # copies into script arena
  @best  = persist_copy(result)       # copies into persistent arena
end
FunctionWhat it does
copy_to_script(x)Copies x into the script arena. Returns the same type.
persist_copy(x)Copies x into the persistent arena. Returns the same type.

For scalars (int, float, bool) the copy is just a write. For strings it copies the bytes into the target arena.

Lifetime Inference

The compiler infers the lifetime of every expression so you rarely need to think about it explicitly. The rules:

ExpressionInferred lifetime
@attr referenceThe attr's declared lifetime
Literal (0, 3.14, true, :atom, "text")Persistent, assignable anywhere
Local variableFrame
f(a, b) call resultMax lifetime of all arguments
a + b, a * b, etc.Max lifetime of both operands
recv.method(a) callMax lifetime of receiver + arguments
copy_to_script(x)Script
persist_copy(x)Persistent

The key rule: if any argument carries script lifetime, the call result is script too. This means expressions that involve @script attrs automatically carry script lifetime, so assigning them back to a @script attr is safe without promotion, the compiler already knows.

Common Patterns

# Tick counter
@ticks :: script = 0
def on_tick(dt :: float) do
  @ticks = @ticks + 1
end

# High score
@best :: persistent = 0
defp record(score :: int) do
  if score > @best do
    @best = persist_copy(score)
  end
end

# Pre-loaded asset (persistent so it survives reload)
@texture :: persistent = 0
def on_init() do
  @texture = load_texture("assets/player.png")
end

# Zero-cost frame scratch buffer
@visible :: frame = array_fixed(128, 0)
def on_tick(dt :: float) do
  # @visible is automatically empty at the start of every tick
  cull_entities(@visible)
  render(@visible)
end