Lifecycle

The Chasm game loop, on_init, on_tick, on_draw, required vs optional hooks, and how dt works.

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

FunctionRequiredWhen calledTypical use
def on_tick(dt :: float)YesEvery frame, before drawingGame logic: movement, physics, AI, input
def on_draw()YesEvery frame, after on_tickDraw calls — no state mutation
def on_init()NoOnce, after the window opensLoad assets, set initial state
def on_unload()NoOnce, before the window closesFlush saves, clean up
def on_reload_migrate()NoAfter each hot-reload swapMigrate 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)
end

on_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 * dt

The 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
end

Fixed 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
end

on_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)
end

clear_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")
end

Use 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())
end

on_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()
end

The 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)
end

Standalone 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
end

Run with chasm run file.chasm. There is no loop, no dt, and no on_draw.