Skip to content

phy: the game engine

An actor is a box that moves and collides. It does not know how to draw itself: the game calls gfx.sprite, and the paint order belongs to the game.

The engine is the same code on the board and in the simulator, and it gives the same positions, bit for bit. That is what makes a trace replayable (see the rules).

Ids are 1-based. A freed slot leaves a hole, and allocation resumes at the smallest free index: the result of a step therefore does not depend on the order in which the game created its actors.

All these functions refuse floats. phy.PX is 65536: write 3 * phy.PX for three pixels, and // where you would have written / (see integers and floats).

The collision mask

phy.map(name | nil) → w, h

Loads the level's collision mask, its geometry: what you walk on, what you bump into. Returns its dimensions in pixels. phy.map(nil) removes all geometry.

Two forms, and the first is the one you write. name.mask is an ASCII drawing, one character per 8 × 8 px cell, 1:1 with the tiles of the background: sixty lines of eighty characters for one screen, # for matter, everything else is air. About 4.9 KiB, which you edit in the simulator's editor like a script, which read fine in a diff, and which the App's Map editor generates from the tiles tagged "solid".

The dimensions are read from the text, the number of lines and the longest line, with the screen as a floor: a world bigger than the screen (up to 256 × 128 tiles) simply writes a bigger file. name.msk is the binary form, one bit per pixel, produced by a tool: for what gets generated, and for the day a mask needs pixel precision. The .mask is tried first.

Two signs, not three: # is solid, everything else is empty. Missing lines are empty, missing characters too. The outside is solid: an actor does not leave the world. Changing the mask empties the world.

phy.solid(x, y) → boolean

The mask at that pixel. This is what lets a game draw its level from its geometry instead of keeping a second copy of it in Lua. The platformer in Your first game builds its list of blocks that way, once, at load time. Outside the map, it returns what the mask says of the outside. Without phy.map(), it is an error.

World and actors

phy.gravity(gx, gy)

The world's gravity vector, in pixels per frame squared. Only actors carrying phy.GRAVITY are subject to it.

phy.new(w, h) → id / phy.free(id)

Creates an actor whose collision box is w × h pixels; without a flag, it hits nothing and does not fall. The box does not derive from the bitmap: a frame that changes parity would make the actor jitter against the ground. The link between the box and the drawing is the anchor. phy.free releases the slot, nothing is moved.

phy.pos(id [, x, y]) → x, y / phy.vel(id [, vx, vy]) → vx, vy

Read, or set then return. In Q16.16 fixed point: phy.pos(a, 320 * phy.PX, 0).

phy.vel writes both components

An axis driven by the physics must be read back before being set again, otherwise gravity is cancelled every frame:

local vx = 0
local _, vy = phy.vel(player)   -- we KEEP the vertical speed acquired so far
if p & pad.LEFT ~= 0 then vx = -WALK end
phy.vel(player, vx, vy)

phy.px(id) → x, y

The position of the box in whole pixels, floored.

phy.box(id [, w, h]) → w, h / phy.anchor(id [, ax, ay]) → ax, ay

The anchor is the offset between the corner of the box and the corner of the bitmap. phy.anchor(a, W // 2, H) makes the actor's position designate the bottom centre of the sprite, its foot.

phy.flags(id [, flags]) → flags

phy.MASK: collides with the collision mask. phy.GRAVITY: subject to the world's gravity. A flag creates nothing: it says what the actor is sensitive to.

phy.layer(id [, layer, mask]) → layer, mask

layer is a number from 0 to 31; mask is the set of layers this actor collides with, 1 << other_layer. Detection is an OR: two actors report each other if either of them asks for it.

One frame of world

phy.step() → crossings, starved

One frame of world, called explicitly from _update(). In order: gravity, speed clamping, movement in x against the mask then in y, ground probe, overlap detection. Returns the number of pixel crossings consumed, to compare with the budget of 4,096, and the number of actors moved without collision because that budget was exhausted. Movement visits every pixel crossed: a shot at 100 px/frame does not go through a wall.

phy.contacts() → n, lost / phy.contact(k) → a, b

The overlaps detected at the last phy.step(), to be walked from 1 to n. Always a < b, and the order is stable from one run to the next. Contacts drain, they do not fire again. The test is done on the swept box, the union of the departure and the arrival, otherwise a fast bullet would pass through a slow enemy.

phy.ground(id) → frames, on_ground / phy.touch(id) → mask

phy.ground returns the number of frames since the last contact with the ground and the coyote time verdict: true up to four frames after leaving a ledge. phy.touch returns what the actor hit during this frame: phy.LEFT, phy.RIGHT, phy.UP, phy.DOWN.

Animations

phy.anim_load(prefix [, rate, loop]) → id, n

Loads prefix0, prefix1, … from the bundle's atlas ('game/flame'game/flame0, game/flame1…) until there are no more, and if nothing comes, tries again with an underscore: 'game/walk' finds game/walk_0, game/walk_1…, the form the bundle editor produces. The separator is never written. Returns the animation's id and its number of images. rate is in frames per image (6 by default); loop is true by default, at false the animation freezes on its last image.

phy.anim(id [, anim]) → anim / phy.anim_done(id) → boolean / phy.sprite(id) → sprite, x, y

Changing animation restarts it from the beginning. phy.anim_done is only true for a finished one-shot animation. phy.sprite returns the three values that feed gfx.sprite exactly:

gfx.sprite(phy.sprite(a))

Each actor has its own counter, reset to zero at its creation: six torches sharing one animation do not beat together.