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.