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.
| Lifetime | Arena reset when | Typical use |
|---|---|---|
frame | Every tick (start of each on_tick) | Per-frame scratch: temp lists, movement deltas |
script | On hot-reload | Player state, score, active entities |
persistent | Never (process exit only) | High score, loaded fonts, config |
Declaring Attributes
@score :: script = 0
@high_score :: persistent = 0
@speed :: script = 400.0
@temp :: frame = 0.0The syntax is:
@name :: lifetime = initial_value
@name, the attribute name. The@prefix appears everywhere it is used, including reads and writes.:: lifetime, one offrame,script, orpersistent.= 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...
endFrame 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")
endLoading 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 + 1Lifetime 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
endThe 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| Function | What 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:
| Expression | Inferred lifetime |
|---|---|
@attr reference | The attr's declared lifetime |
Literal (0, 3.14, true, :atom, "text") | Persistent, assignable anywhere |
| Local variable | Frame |
f(a, b) call result | Max lifetime of all arguments |
a + b, a * b, etc. | Max lifetime of both operands |
recv.method(a) call | Max 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