Chasm scripts are driven by a game engine that calls three functions at specific points in time. Understanding when each is called, and what dt actually is, is fundamental to writing correct game logic.
The Three Entry Points
| Function | When called | Typical use |
|---|---|---|
def on_init() | Once, before the first tick | Load assets, set initial state |
def on_tick(dt :: float) | Every frame, before drawing | Game logic: movement, physics, AI, input |
def on_draw() | Every frame, after on_tick | Draw calls only, no state mutation |
All three are optional. If you don't define one, the engine skips it.
on_init
on_init runs exactly once when the script is first loaded. Use it to load assets and set up state that cannot be expressed as a simple default value:
@font :: persistent = 0
@texture :: persistent = 0
@map :: persistent = ""
def on_init() do
@font = load_font("assets/mono.ttf")
@texture = load_texture("assets/player.png")
@map = file_read("levels/1.json")
endThese are persistent attributes, on hot-reload, on_init does not run again, and the loaded assets are preserved. Only the script and frame arenas reset.
on_init and hot reload:
on_init is called once per process start, not once per reload. If you put state
initialization in on_init that belongs in script attrs, it will not reset when you
hot-reload. Use script attr defaults for game state that should reset on reload.
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 * 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 :: script = 0.0
@speed :: script = 200.0 # pixels per second
@timer :: script = 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 # reset
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(:space) do
fire_bullet()
end
endon_draw
on_draw is for rendering only. State mutations here are discouraged, they run after the physics/logic step and before the next on_tick, so mutations can cause a one-frame lag or subtle ordering bugs:
def on_draw() do
# Draw the background
draw_rectangle(0.0, 0.0, screen_w, screen_h, @bg_color)
# Draw the player
draw_texture(@texture, @player_x, @player_y, 32.0, 32.0, 0xffffffff)
# Draw the HUD
draw_text("Score: #{@score}", 10.0, 10.0, 20, 0xffffffff)
draw_text("Lives: #{@lives}", 10.0, 35.0, 20, 0xffffffff)
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.
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
endRun with chasm run file.chasm. There is no loop, no dt, and no on_draw.