Skip to content

The Engine Interface

A wick program talks to the lantern engine through the lt namespace. Every lt.* call is registered with a typed signature that the compiler checks: wrong arity or a wrong argument type is a compile error naming the call and the argument position, exactly as for your own functions. This chapter describes the frame contract that structures every game, documents the API by area, and then covers the two properties — determinism and verifiability — that the engine and language were co-designed to provide.

The frame contract

The engine drives the game by calling two functions it looks up by name, once each per frame, at 60 frames per second:

fn update(dt: num) { }   // simulation; dt is seconds since the last frame
fn draw() { }            // rendering; 3D calls first, 2D composites on top

update(dt) advances the simulation. Its argument dt is the elapsed time in seconds, capped at 0.1 so that a long stall (a breakpoint, a slow load) cannot produce a single enormous step that tunnels the player through a wall. draw() renders the frame; 3D calls are issued first and the 2D calls composite on top, so a HUD drawn with lt.print and lt.rect always sits over the scene.

Both functions are optional. A main.wick with neither is legal — it runs its top-level statements once and then presents a static frame. Top-level statements, everything not inside a fn, run exactly once at load, and that is where a game creates its meshes, loads its textures and sounds, and initialises its state. The order is fixed and worth holding in mind: top-level once, then update and draw every frame, then collection at the frame boundary (Chapter 7).

Frame and screen

Call Notes
lt.W lt.H compile-time constants: 400, 240
lt.clear(r, g, b) clear colour and depth; call first in draw()
lt.time(): num seconds since boot (fixed-step under LANTERN_FIXED_DT)
lt.screenshot(path: str) save the 400×240 frame as BMP
lt.quit() request a clean exit
lt.escape_quits(enable: bool) Escape quits by default; disable for pause menus

lt.W and lt.H are numeric constants, not calls — they resolve at compile time to 400 and 240 — so lt.W / 2 is a constant expression you can use freely for layout. The screen is always 400 by 240; the engine scales the presented image to the window.

The 3D scene

Call Notes
lt.camera(ex,ey,ez, tx,ty,tz, fov=55) eye position and look-at target
lt.light(dx,dy,dz, ambient=0.35) the directional light
lt.point_light(i, x,y,z, radius, r=1,g=1,b=1) slots 0–3; radius <= 0 turns the slot off
lt.fog(start, end, r,g,b) linear by view depth; end <= start disables

Trailing parameters with defaults — fov, ambient — may be omitted at the call site; the compiler fills the default in, so a native always receives full arity. There are four point-light slots, indexed 0 through 3; setting a slot’s radius to zero or below disables it, which is how Lantern Night extinguishes a lamp:

if lamp_lit[i] {
  lt.point_light(i, lamp_x[i], 0.8, lamp_z[i], 3.6, 1.0, 0.70, 0.28)
} else {
  lt.point_light(i, 0, 0, 0, 0, 0, 0, 0)   // radius 0: off
}

Meshes

Call Notes
lt.cube(): num unit cube
lt.plane(segs=1): num unit XZ plane, tessellated
lt.sphere(seg=12), lt.cylinder(seg=16), lt.cone(seg=16) unit primitives
lt.mesh(verts: list<num>): num custom mesh, 12 floats per vertex
lt.load_mesh(path: str): num Wavefront OBJ (flat-normal fallback)
lt.draw(m, x,y,z, rx,ry,rz, sx,sy,sz, r,g,b, tex=-1) gouraud-lit draw (rotations, scales, colour default)
lt.draw_lerp(a, b, t, x,y,z, …, tex=-1) keyframe tween between two same-count meshes
lt.billboard(tex, x,y,z, w,h, u0,v0,u1,v1) camera-facing quad (sprite characters)
lt.shadow(x,y,z, radius, alpha=0.35) grounding blob

Every mesh constructor returns a num handle; there is no mesh type, because handles are opaque integers the engine owns. lt.mesh takes a flat list<num> of twelve floats per vertex — position (3), normal (3), texture coordinates (2), and colour (4) — which is a natural fit for the flat-list discipline of Chapter 5. lt.draw has a long tail of defaulted parameters, so the common case stays short:

let ground = lt.plane(12)
lt.draw(ground, 0, 0, 0, 0, 0, 0, 15, 1, 15, 0.42, 0.40, 0.40)

2D

Call Notes
lt.rect(x,y,w,h, r,g,b, a=1) filled, alpha-blended
lt.load_texture(path: str): num BMP; magenta (255,0,255) is transparent
lt.sprite(tex, x,y, sx=1,sy=1) top-left anchored
lt.sprite_ex(tex, cx,cy, sx=1,sy=1, rot=0, r=1,g=1,b=1,a=1) centre anchor, rotate, tint; negative scale flips
lt.sprite_uv(tex, x,y,w,h, u0,v0,u1,v1) atlas sub-rect (tilemaps)
lt.print(text: str, x,y, r=1,g=1,b=1,a=1) built-in 8×8 font, \n aware

lt.print renders in a built-in 8-by-8 pixel font and understands \n. Because + concatenates only str with str, building a HUD line means converting numbers explicitly with str — which, unlike Lua’s string.format, prints integers cleanly with no trailing decimal:

lt.print("LAMPS RELIT: " + str(score), 4, 3, 1, 1, 1, 0.95)

Audio, input, and saves

Call Notes
lt.load_sound(path: str): num WAV
lt.play(sound, volume=1, loop=0): num returns a channel, or −1 if all 16 are busy
lt.stop(channel) lt.volume(v)
lt.key(name: str): bool held; keyboard and gamepad merged
lt.pressed(name: str): bool went down this frame
lt.gamepad(): bool lt.rumble(low, high, ms)
lt.save(name: str, data: str): bool binary-safe
lt.load_save(name: str): str? optional — nil when unreadable

lt.key is true while a key is held; lt.pressed is true only on the frame the key goes down — the difference between continuous movement and a one-shot action. The recognised input names are:

left right up down z x c space return escape a s d w

Keyboard and gamepad are merged onto these names: the pad’s d-pad and stick map to the directions, and its face buttons map A to z, B to x, Y to c, and Start to return. So a game written against lt.key("z") works with either input with no extra code.

The one call in this table that returns an optional is lt.load_save, and it is no accident that the chapter which motivates the whole language turns on it. Its signature is load_save(str): str?; the compiler will not let you use the result until you have handled the nil, which is the entire point of Chapter 4.

Determinism

wick and lantern are built so that the same program with the same inputs produces the same frames on every machine. Two facilities make this true, and a testing culture is built on top of them.

Deterministic randomness.

The built-in rand() returns a uniform value in [0, 1) from a fixed xorshift64* generator — the same sequence on every machine. There is no time-seeded mode. If you want variation between runs, you seed it yourself with srand(seed), using a number you chose and can log:

srand(7)                    // same layout every run, on every machine
let stars: list<num> = []
for i in 0..40 {
  push(stars, rand() * 400)
  push(stars, rand() * 240)
}

Because the sequence is fixed, a seeded run replays identically, which is what makes screenshot tests meaningful — the “random” starfield is the same starfield in CI as on your desk.

Fixed-step time.

lt.time() returns seconds since boot, and under the environment variable LANTERN_FIXED_DT the frame clock advances by a fixed step regardless of wall-clock speed. Combined with deterministic rand(), this makes an entire gameplay session reproducible frame for frame.

Screenshot capture.

Setting LANTERN_SHOT=/tmp/x tells the engine to capture frame 60 as a BMP and exit. With LANTERN_FIXED_DT=1 alongside it, that capture is byte-identical everywhere. The two together turn “does it still look right” into a mechanical check:

LANTERN_SHOT=/tmp/x LANTERN_FIXED_DT=1 ./build/lantern games/mygame

A culture of verification

Determinism is only useful if the game can drive and check itself, and wick gives two built-ins for exactly that.

Self-play through env.

env(name): str? reads an environment variable — an optional, nil when unset. A game can expose its own self-play switch and branch on it, so CI runs the game on autopilot with a fixed seed:

let AUTO = env("SHOWCASE_AUTO") != nil   // deterministic self-play (CI)
if AUTO { srand(7) }

In Lantern Night, AUTO drives the player toward the nearest dark lamp instead of reading the keyboard, so a headless CI run plays a real, deterministic round and the frame-60 screenshot is a true regression test.

Assertions with check.

check(cond: bool, msg: str) is a runtime assertion: when cond is false it stops the frame and shows check failed: <msg> on the error screen. The wick test suite is built from these — a scene that sets up a known state and checks the results, run under fixed-step time so the outcome is exact:

check(len(lamp_x) == 4, "expected four lamps")
check(nearest_unlit() == -1, "all lamps should start lit")

Together, env-driven self-play, deterministic rand, fixed-step time, and check turn a game into something that tests itself the same way on every machine — the practical payoff of designing the language and the engine for determinism from the start.