Your first game¶
A papyDeck game is a handful of files in one folder, and a program of two functions. This page goes from the empty folder to a platformer in 75 lines, using two of the example games. The API they use is described in the rules, gfx and phy.
A game is a folder¶
A game is a folder: its .lua files and its assets, side by side, and it starts with main.lua, always. That is not a setting. The folder is flat: no subfolders, no path anywhere. It holds:
main.lua, and the modules the game loads withrequire("name"), which asks the game folder forname.luaand nowhere else;- the collision masks of its levels,
name.mask, text files read byphy.map("name"); - the pieces of its art bundles, in palette groups:
<g>.paland<g>.atlaswith the indexed PNGs the atlas names (a palette and the images quantised with it), the palette variants<g>.<v>.pal, and the tile backgrounds<g>-<f>.map+<g>-<f>-tiles.png. There is no manifest: the prefixes make the links, and one call togfx.bundle()installs everything.
File names follow the rule of the console's micro-SD card, the same one the App enforces: lowercase [a-z0-9_-] only, and never a reserved Windows name such as con or lpt1. FAT32 does not distinguish case, so a /, a .. or an upper-case letter in a require is an error, not a warning.
PNGs are accepted in indexed colours only, the native output of Aseprite. Index 0 is transparent in a sprite, and it is also the hole of a background.
The skeleton¶
gfx.bundle() -- game.pal, game.atlas, game-*.png, game-*.map
local ship = gfx.id('game/ship') -- the id of a region: <group>/<name>
local flame = phy.anim_load('game/flame', 6)
function _update() -- the logic, once per frame
local p = pad.get() -- any pad
...
phy.step()
end
function _draw() -- DESCRIBES the frame: nothing is drawn here
gfx.sprite(ship, x, y)
gfx.print(8, 8, 'SCORE ' .. score, 2)
end
The top of the file runs once, at load: it installs the bundle, looks up the ids of the images it will draw, creates its actors. Then _update() and _draw() are called at every frame, in that order.
_update() is the logic: it reads the pads, moves things, calls phy.step(). There is no dt, and there never will be: the logical frame is the call to _update(), speeds are in pixels per frame. The console slows down, it never skips.
_draw() describes the frame. It draws nothing itself: every gfx.sprite() adds an entry to the display list, and the order of the calls is the paint order, the last one in front. The display list restarts between _update() and _draw(): only the gfx.sprite calls made from _draw() show. And _draw() must not modify the state of the game, because under load the console may skip it.
A sprite that follows the D-pad¶
The ship example, in full:
-- A sprite that follows the D-pad. That is the whole program.
gfx.bundle() -- the game's assets: game.pal, game.atlas, game-*.png
local ship = gfx.id('game/ship')
local x, y = 320, 240
function _update()
local p = pad.get(1)
if p & pad.LEFT ~= 0 then x = x - 2 end
if p & pad.RIGHT ~= 0 then x = x + 2 end
if p & pad.UP ~= 0 then y = y - 2 end
if p & pad.DOWN ~= 0 then y = y + 2 end
end
function _draw()
gfx.sprite(ship, x, y)
end
Its folder holds four files:
| File | What it is |
|---|---|
main.lua |
the program above |
game.pal |
the palette of the group game, 768 bytes |
game.atlas |
the atlas of the group: which PNG holds which region, and where |
game-ship.png |
the indexed PNG the atlas names, with the region ship in it |
gfx.bundle() installs the group; gfx.id('game/ship') is the id of the region ship of the group game, what gfx.sprite takes. pad.get(1) is a bit mask: p & pad.LEFT ~= 0 tests one button. The position is a plain number in pixels; two pixels per frame is 120 pixels per second.
The .pal and .atlas files are not written by hand. They come from the bundle editor of the App, which builds a group from your PNGs and quantises them together, or from the game folder of an example, which you can copy and change.
A platformer in 75 lines¶
The plateforme example. Its comments explain what the engine removes from a game; they are translated here.
-- papyDeck: what a game engine removes from the code of a game.
--
-- There is NO collision test here, NO integration, NO clamping to the edges,
-- NO handling of the contact with the ground. All of that is in the engine,
-- in fixed point, and replays bit for bit on the three targets.
--
-- Positions are in Q16.16: phy.PX is 65536, so "3 * phy.PX" reads
-- "three pixels". They NEVER travel through a `number`: a Lua number is a
-- float32 here, and a position beyond 256 px would lose its low bits in it.
-- THE LEVEL IS A TEXT FILE, and it exists in one copy only.
--
-- niveau.mask: one character per 8x8 px cell, 1:1 with the background tiles,
-- "#" for matter, 80 x 60 for one screen. The console takes its geometry
-- from it, and the game READS IT BACK below to place its blocks, so the
-- scenery drawn cannot lie about what you bump into. Open it and turn a
-- dot into a hash: the block appears, and it holds.
gfx.bundle() -- the game's assets: game.pal, game.atlas, game-*.png
local W, H = phy.map("niveau")
phy.gravity(0, phy.PX // 2) -- 0.5 px per frame, per frame
local bloc = gfx.id('game/bloc')
local BLOC = 16
-- The table is built ONCE: phy.solid() probes at the pixel, and asking
-- 1,200 tiles at every frame would be paying twelve hundred calls for an
-- image that does not move.
local blocs = {}
for ty = 0, H // BLOC - 1 do
for tx = 0, W // BLOC - 1 do
if phy.solid(tx * BLOC + BLOC // 2, ty * BLOC + BLOC // 2) then
blocs[#blocs + 1] = { tx * BLOC, ty * BLOC }
end
end
end
-- THE ANIMATION TOO belongs to the engine: a prefix, a rate in frames per
-- image, and each actor carries its own counter. Without it every game
-- copied the same boilerplate: tables of gfx.id() and a t//4 % n.
local marche = phy.anim_load('game/perso', 8) -- perso0, perso1
local joueur = phy.new(gfx.size(gfx.id('game/perso0')))
phy.flags(joueur, phy.MASK | phy.GRAVITY)
phy.pos(joueur, 32 * phy.PX, 400 * phy.PX)
phy.anim(joueur, marche)
local MARCHE = 3 * phy.PX
local SAUT = -10 * phy.PX
function _update()
local p = pad.get(1)
local _, vy = phy.vel(joueur)
local vx = 0
if p & pad.LEFT ~= 0 then vx = -MARCHE end
if p & pad.RIGHT ~= 0 then vx = MARCHE end
-- Coyote time is in the engine: `au_sol` stays true for a few frames after
-- a ledge, otherwise the jump fails at every platform edge.
local _, au_sol = phy.ground(joueur)
if au_sol and p & pad.A ~= 0 then vy = SAUT end
phy.vel(joueur, vx, vy)
phy.step()
end
function _draw()
for _, b in ipairs(blocs) do
gfx.sprite(bloc, b[1], b[2])
end
-- phy.sprite returns the exact triplet of gfx.sprite: the frame of the
-- moment, and the position of the actor in whole pixels.
gfx.sprite(phy.sprite(joueur))
end
The folder holds main.lua, niveau.mask, game.pal, game.atlas, game-bloc.png (a 16 × 16 block) and game-perso.png (the two 12 × 16 images of the character, perso0 and perso1, side by side in the atlas).
The level is niveau.mask, an ASCII drawing: sixty lines of eighty characters, one character per 8 × 8 px cell, # for matter, everything else is air. An excerpt, the bottom of the level:
................................................................................
################................................................................
################................................................................
................................................................................
................................................................................
................................................................................
................................................................................
................................................................................
################################################################################
################################################################################
phy.map("niveau") loads it and returns the dimensions of the world in pixels, 640 × 480 here. Missing lines are empty, missing characters too, and the outside is solid: an actor does not leave the world.
Gravity is a vector in pixels per frame squared: phy.gravity(0, phy.PX // 2) is half a pixel per frame, per frame. Note the //: everything phy.* takes is Q16.16 fixed point, integers only, and / would produce a float.
The block list is built once, at load, with phy.solid(x, y), which asks the collision mask what it says at that pixel. The game probes the centre of every 16 × 16 cell and keeps the solid ones; _draw() then draws one block sprite per entry. The level exists in one copy: the drawing cannot disagree with the geometry.
The animation is phy.anim_load('game/perso', 8): the regions perso0, perso1… of the group game until there are none left, at a rate of 8 frames per image, looping.
The actor is phy.new(w, h), with the box the size of the first image, phy.MASK to bump into the mask and phy.GRAVITY to fall, and a starting position posed in Q16.16, 32 * phy.PX, 400 * phy.PX.
The vel pattern in _update() is the one to remember: phy.vel writes both components, so the vertical speed acquired from gravity is read back first (local _, vy = phy.vel(joueur)) and posed again with the horizontal one. Without that, gravity is cancelled at every frame.
Coyote time is phy.ground(joueur): its second value stays true up to four frames after leaving a ledge, so a jump pressed a little late still goes.
phy.step() is one frame of the world, called explicitly: gravity, movement in x against the mask then in y, the ground probe, the overlaps.
phy.sprite(joueur) returns the three values gfx.sprite takes, the image of the moment and the position in whole pixels, so gfx.sprite(phy.sprite(joueur)) is the whole drawing of the character. The blocks are drawn first and the character last: the order of the calls is the paint order.
Run it¶
In the simulator, the game is a cloud project: clone an example from the published list, or start a draft of your own. The arrows are the D-pad and Z is the A button: it fires in the shooter, it jumps in the platformer. Edit, press Ctrl+Enter, and the game runs again. See the simulator.
On the board, the folder goes on the micro-SD card, under papydeck/games/<name>/, one folder per game. The console lists the games on the card and launches the one you pick; the first launch compiles the art bundle into the NOR flash, a few seconds, and nothing afterwards.
The same file, the same API, the same cost model: a game that runs in one runs in the other.