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 = 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 tickThe 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.:: lifetime— optional. Omit it for the common case (script). Only write:: persistentor:: framewhen 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 want | Write |
|---|---|
| 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 = :playingScript 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 assets —
load_fontandload_texturereturn handles. The handles must outlive hot-reloads, and loading is expensive. Put them inon_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
endIf 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)
endFor 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 storing | Write |
|---|---|
| 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 function | Local 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...
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 = 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")
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 Inference
The compiler infers the lifetime of every expression. The rules:
| Expression | Inferred lifetime |
|---|---|
@attr reference | The 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 variable | Inferred from its assigned value |
a + b, a * b, etc. | Max lifetime of both operands |
f(a, b) call result | Max lifetime of all arguments |
recv.method(a) call | Max 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
endLifetime 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
endThe 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
endPromotion 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| 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.
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 # OKCommon 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