Chasm scripts are driven by a game engine that calls specific functions at specific points in time. Understanding which functions are required, when each is called, and what dt actually is, is fundamental to writing correct game logic.
The Entry Points
| Function | Required | When called | Typical use |
|---|---|---|---|
def on_tick(dt :: float) | Yes | Every frame, before drawing | Game logic: movement, physics, AI, input |
def on_draw() | Yes | Every frame, after on_tick | Draw calls — no state mutation |
def on_init() | No | Once, after the window opens | Load assets, set initial state |
def on_unload() | No | Once, before the window closes | Flush saves, clean up |
def on_reload_migrate() | No | After each hot-reload swap | Migrate state across reloads |
on_tick and on_draw are required. The engine will refuse to start if either is missing. The others are optional — the engine uses a no-op stub if they are not defined.
Minimum viable script:
A script that runs must define at least on_tick and on_draw. Even if they are empty:
def on_tick(dt :: float) do
end
def on_draw() do
clear_background(0x181820ff)
endon_tick and dt
on_tick(dt) is called every frame. dt, short for delta time, is the number of seconds elapsed since the previous frame.
At 60 fps, dt ≈ 0.01667 (about 16.7 milliseconds). At 30 fps, dt ≈ 0.03333. On a lagging frame it might be 0.05 or more.
Why multiply by dt?
Without dt, movement speed is tied to the frame rate. A game running at 60 fps would move twice as fast as one at 30 fps:
# Wrong — speed depends on frame rate
@x = @x + 5.0
# Correct — moves 300 units per second regardless of frame rate
@x = @x + 300.0 * dtThe rule: any quantity that represents rate of change per second must be multiplied by dt. Position change, velocity application, timer countdowns, animation progress — all of these.
@player_x = 0.0
@speed = 200.0 # pixels per second
@timer = 3.0 # seconds until next wave
def on_tick(dt :: float) do
# Movement: pixels per second × seconds = pixels this frame
@player_x = @player_x + @speed * dt
# Countdown: subtract seconds elapsed this frame
@timer = @timer - dt
if @timer <= 0.0 do
spawn_wave()
@timer = 3.0
end
endFixed values don't need dt
If something happens exactly once (a key press fires a bullet, a collision removes an enemy), do not multiply by dt. dt is only for continuous rates:
def on_tick(dt :: float) do
# Continuous — multiply by dt
@player_x = @player_x + @velocity_x * dt
# Discrete event — no dt
if key_pressed(key_code(:space)) do
fire_bullet()
end
endon_draw
on_draw is for rendering only. Keep all game logic in on_tick. State mutations in on_draw are discouraged — they run after the physics step and before the next on_tick, which can cause one-frame lag or subtle ordering bugs.
def on_draw() do
clear_background(0x181820ff)
draw_texture(@texture, @player_x, @player_y, 0xffffffff)
draw_text("Score: #{@score}", 10.0, 10.0, 20, 0xffffffff)
draw_text("Lives: #{@lives}", 10.0, 35.0, 20, 0xffffffff)
endclear_background must be called at the start of on_draw to clear the previous frame. Without it, frames will stack on top of each other.
on_init
on_init runs exactly once when the script is first loaded — after the window opens and before the first tick. Use it to load assets and set up state that cannot be expressed as a simple default value:
@font :: persistent = 0
@texture :: persistent = 0
def on_init() do
@font = load_font("assets/mono.ttf")
@texture = load_texture("assets/player.png")
endUse persistent attrs for handles loaded in on_init. On hot-reload, on_init does not run again — persistent attrs survive the reload and the loaded assets remain valid.
on_init and hot reload:
on_init runs once per process start, not once per reload. Game state that should reset on
hot-reload belongs in script attr defaults, not on_init. Only use on_init for things
that are expensive to re-load (textures, fonts, sounds).
on_unload
on_unload is called once before the window closes. Use it to flush save files or clean up external resources:
def on_unload() do
file_write("save.json", serialize_save())
endon_reload_migrate
on_reload_migrate is called after each hot-reload swap, once the new script is loaded and the script arena is reset. Use it to re-derive any cached state that was lost in the reset:
def on_reload_migrate() do
rebuild_spatial_grid()
endThe Full Frame Sequence
Each frame looks like this:
1. Frame arena reset — all :: frame attrs reinitialize to their defaults
2. on_tick(dt) — your game logic runs
3. on_draw() — your draw calls execute
4. Present frame — the engine displays the result
The frame arena reset (step 1) is a single pointer operation — no loops, no cleanup. This is why :: frame attrs are essentially free scratch space.
A Complete Script
@player_x = 400.0
@player_y = 300.0
@speed = 200.0
@font :: persistent = 0
def on_init() do
@font = load_font("assets/mono.ttf")
end
def on_tick(dt :: float) do
dx :: frame = 0.0
dy :: frame = 0.0
if key_down(key_code(:right)) do dx = @speed end
if key_down(key_code(:left)) do dx = 0.0 - @speed end
if key_down(key_code(:down)) do dy = @speed end
if key_down(key_code(:up)) do dy = 0.0 - @speed end
@player_x = copy_to_script(@player_x + dx * dt)
@player_y = copy_to_script(@player_y + dy * dt)
end
def on_draw() do
clear_background(0x181820ff)
draw_rectangle(@player_x - 16.0, @player_y - 16.0, 32.0, 32.0, 0x4488ffff)
draw_text_ex(@font, "Move with arrow keys", 10.0, 10.0, 18.0, 1.0, 0xffffffff)
endStandalone Programs
For scripts without an engine, use def main() instead:
def main() do
print("Hello from Chasm!")
for i in 0..10 do
print(fib(i))
end
endRun with chasm run file.chasm. There is no loop, no dt, and no on_draw.