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                 = 0      # script — default, no annotation needed
@speed                 = 400.0  # script — default
@high_score :: persistent = 0   # must survive hot-reload
@scratch    :: frame      = array_fixed(64, 0)  # auto-clears each tick

The syntax is:

@name = initial_value                 # script lifetime (default)
@name :: persistent = initial_value   # write this when you need persistent
@name :: frame      = initial_value   # write this when you need frame
  • @name — the attribute name. The @ prefix appears everywhere it is used.
  • :: lifetimeoptional. Omit it for the common case (script). Only write :: persistent or :: frame when you explicitly need a different lifetime.
  • = initial_value — evaluated once when the module is first loaded.

When is :: lifetime actually required?

On module attributes (@name): only two annotations ever matter:

What you wantWrite
State that resets on reload — the common case@name = value (no annotation)
Survives hot-reload@name :: persistent = value
Auto-clears at the start of every tick@name :: frame = value

:: script is never written. :: frame on an @attr is not inferred — without it the attr defaults to script and will not auto-clear each tick.

Inside functions (local variables): never write a lifetime annotation. The compiler always infers it from the right-hand side — including for arrays, temporaries, and values derived from function parameters.

Choosing a Lifetime

The question to ask is: what happens if this value disappears?

Most attrs: just write @name = value

Script is the default. You do not write :: script — ever. Just declare the attr:

@player_x   = 400.0
@player_y   = 300.0
@score      = 0
@lives      = 3
@enemies    = array_fixed(8, Enemy{})
@game_state = :playing

Script attrs reset when you hot-reload. During development this is a feature — reload after fixing a bug and the game starts from its declared defaults.

If you are not sure which lifetime to use, write nothing — the default is right.

Use :: persistent for values that must survive reload

Persistent attrs never reset. Reach for persistent when:

  • Loading assetsload_font and load_texture return handles. The handles must outlive hot-reloads, and loading is expensive. Put them in on_init, which only runs once per process start.
  • High scores or saved progress — you want the player's best run to survive fixing a bug and reloading.
  • Configuration — values you set once at startup and never want reset.
@font       :: persistent = 0
@high_score :: persistent = 0

def on_init() do
  @font = load_font("assets/mono.ttf")   # runs once; handle lives forever
end

If you accidentally make a font handle script, reloading the script frees the handle and crashes your draw call. If you make a score persistent when it should be script, it never resets between playthroughs. Choose persistent deliberately — it is the exception, not the default.

Use :: frame for tick-scoped scratch buffers

Frame attrs reset at the start of every on_tick automatically — no manual .clear() needed. This is most useful for pre-allocated arrays you fill each tick and discard:

@visible :: frame = array_fixed(128, 0)

def on_tick(dt :: float) do
  # @visible is always empty at the start of this tick
  cull_entities(@visible)
  render_visible(@visible)
end

For intermediate values inside a function, use a local variable — no annotation ever needed, the compiler always infers the lifetime automatically.

Quick reference

What you are storingWrite
Player position, velocity, health@name = value
Score, lives, level@name = value
Bullet / enemy arrays@name = value
High score, saved config@name :: persistent = value
Font or texture handle (set in on_init)@name :: persistent = 0
Scratch array that auto-clears each tick@name :: frame = array_fixed(...)
Intermediate value inside a functionLocal variable — no annotation, ever

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   = 0
@lives   = 3
@player  = Player{}
@enemies = 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 Inference

The compiler infers the lifetime of every expression. The rules:

ExpressionInferred lifetime
@attr referenceThe attr's declared lifetime
Literal (0, 3.14, true, :atom, "text")Persistent, assignable anywhere
Function parameter (x :: int)Frame — always conservatively treated as frame
Local variableInferred from its assigned value
a + b, a * b, etc.Max lifetime of both operands
f(a, b) call resultMax lifetime of all arguments
recv.method(a) callMax lifetime of receiver + arguments
copy_to_script(x)Script (regardless of x's lifetime)
persist_copy(x)Persistent (regardless of x's lifetime)

Local variable lifetime is inferred from the right-hand side, not assumed to be frame. A local assigned from persist_copy(x) has persistent lifetime. One assigned from a @script attr has script lifetime. One assigned from an integer literal has persistent lifetime. The inferred lifetime propagates through chains of assignments, so you never write lifetime annotations on locals — the compiler always infers them.

Function parameters are always frame. The compiler cannot know what the caller passed, so it treats all parameters conservatively as frame. To store a parameter into a @script or @persistent attr, use an explicit promotion call.

Literals are persistent. An expression mixing a @script attr with a literal (like @count + 1) infers persistent, because max(script, persistent) = persistent. Arithmetic with literals alone does not trigger E008.

@score                 = 0   # script (default)
@high_score :: persistent = 0

def update(bonus :: int) do
  # bonus is a function parameter — frame lifetime.
  # @score is script. combined infers max(script=2, frame=1) = script.
  combined = @score + bonus             # combined: script

  # Chaining: promoted picks up persistent from persist_copy.
  promoted = persist_copy(combined)     # promoted: persistent
  @high_score = promoted                # OK — no annotation needed on promoted
end

Lifetime Safety, E008

The compiler enforces that values only flow upward along the lifetime hierarchy. Assigning a value whose inferred lifetime is shorter than the target attr is a compile error (E008):

@score                 = 0   # script (default)
@high_score :: persistent = 0

def update() do
  @high_score = @score               # E008: script cannot flow into persistent
  @high_score = persist_copy(@score) # OK: explicitly copied into persistent arena
end

The error message names the lifetimes involved and suggests the fix.

A common source of confusion: dt * 2.0 where dt is a function parameter does not trigger E008 when assigned to a @persistent attr. Because 2.0 is a literal (persistent), max(frame, persistent) = persistent — the literal dominates. E008 only fires when the result is genuinely script or frame, as when two @script attrs are combined without any literal broadening:

@score                = 0   # script (default)
@count                = 0   # script (default)
@ratio :: persistent  = 0

def tick() do
  @ratio = @score + @count               # E008: max(script, script) = script < persistent
  @ratio = persist_copy(@score + @count) # OK
end

Promotion Functions

When a value is genuinely shorter-lived than the target attr, use an explicit promotion call to copy it into the right arena:

@saved                = 0   # script (default)
@best  :: persistent  = 0

def on_tick(dt :: float) do
  # dt is a function parameter — always frame lifetime.
  @saved = copy_to_script(dt)   # copies into script arena
  @best  = persist_copy(dt)     # 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.

Once a value has been promoted, the compiler knows its new lifetime and you can chain through locals without re-annotating:

promoted = persist_copy(delta)   # persistent
backup   = promoted              # also persistent (inferred from promoted)
@best    = backup                # OK

Common Patterns

# Tick counter — no annotation (script is the default)
@ticks = 0
def on_tick(dt :: float) do
  @ticks = @ticks + 1
end

# High score — :: persistent required (must survive hot-reload)
@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