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, one sign per cell (table below). About 4.9 KiB, which you edit in the App's workshop like a script, which read fine in a diff, and which compiling a level in the workshop generates from its map's collision layer (prefilled from the tiles tagged "solid") and the masks of its placed decors.
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.
A cell has faces, and a face only blocks the edge that crosses it (core v4). The top face stops feet coming down into the cell, and is what you stand on; the bottom face stops a head going up; the left face stops a right edge moving right; the right face stops a left edge moving left. An actor already overlapping a cell is never pushed out of it. That is what makes a platform you can jump through from below, with no special case.
| Sign | Value | Cell |
|---|---|---|
. |
0 | air (anything the reader does not know is air too) |
# |
15 | solid: the four faces |
= |
1 | platform: the top face only — landed on, crossed from below |
H |
16 | ladder: air with rungs (phy.LADDER). The top cell of a ladder gets a top face when the mask loads, so it is a floor; never written in the file |
a..o |
1..15 | any set of faces, ` + value: e = top + left, a one-way wall |
Missing lines are empty, missing characters too. The .msk binary form is solid (15) wherever a bit is set. The outside is solid: an actor does not leave the world. Changing the mask empties the world. An actor placed by phy.pos inside a cell is not standing on it: it sinks to the next cell boundary and lands there. The workshop places an object's feet on a boundary.
phy.solid(x, y) → boolean¶
Something blocks at that pixel: the cell has at least one face (a ladder alone is air, its top is not). 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.
phy.cell(x, y) → flags¶
The cell under that pixel, as flags: phy.FACE_TOP (1), phy.FACE_BOTTOM (2), phy.FACE_LEFT (4), phy.FACE_RIGHT (8), phy.LADDER (16); a solid is 15, the top of a ladder phy.LADDER | phy.FACE_TOP. Outside the map: 15 if the outside is solid, 0 otherwise. phy.cell(x, y) & phy.FACE_TOP ~= 0 says "a floor here", & phy.LADDER ~= 0 "a rung here". These are not phy.LEFT…phy.DOWN, which are the sides of phy.touch.
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:
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. phy.DROP: going down, ignores the top faces of cells that have no bottom face (the platforms, the top of a ladder), never a solid: set it to climb down a ladder or to drop through a platform, clear it once through. 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 compiling a level produces ('level2/torch_4': a model state, single-frame ones included, level2/torch_4_0). 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. The console holds 64 animations at a time: a 65th is refused until one is freed.
phy.anim_free(anim)¶
Frees an animation: its slot serves the next phy.anim_load. The 64 count per level loaded, not per game: a game with several levels frees the animations of one before loading the next, which is what M.decharger() of a generated <level>.lua does. An actor that carried the freed animation loses it (phy.anim(a) returns 0 and phy.sprite(a) refuses) rather than silently taking the images of whatever reuses the slot. Freeing twice is an error. gfx.bundle() frees them all, since it renumbers the images.
phy.anim(id [, anim [, phase]]) → 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:
Each actor has its own counter, reset to zero at its creation — but actors created in the same frame all start from zero at the same instant, so six torches sharing one animation do flicker together. phase, in frames, is where the animation starts for this actor: phy.anim(t, flame, math.random(0, 6 * 8 - 1)) spreads six torches over a full cycle of eight images at six frames each. A phase alone, with the same animation, re-phases without restarting.
The level the App generates¶
Compiling a level in the App writes <level>.lua into the project, with papyrun.lua, the runtime of the behaviours: the dimensions, the background, the gravity, the placed models with their animations, the objects with their properties, the behaviours compiled, and six functions — charger() (background, phy.map, gravity, animations loaded once), creer(model, x, y) (an actor: box, anchor, position, default state), instancier(factories) (the visible objects, per model, their behaviours started), update() (every behaviour, then phy.step() and the contacts), dessiner() (every object, camera applied) and decharger() (the level's animations freed, before the next level). The game writes local level = require("<level>"), calls level.update() and level.dessiner() every frame, and reads the shared table pot; see Working in the App.