Study notes on Pico-8 movement
Today, I’m learning about movement in Pico-8 and other optimizations to improve game feel. This includes the following:
- Basic sprite movement. How I move an 8x8 sprite on four directions from a button-press.
- Smoother tile transition. How to remove the jarring effect of moving from tile to tile.
- Input buffering. How to improve game feel so that button inputs are rendered properly.
These are my study notes while going through LazyDev’s roguelike tutorial, so be sure to check that one out! I’ll try to post my code on Github and build some minimal examples in the process. Lastly, these posts assume a basic knowledge of Pico-8 (e.g., game loop, basic Lua, etc.), so I’ll skip over some parts.
Game loop revisited
Pico-8’s primary mechanism is defined by the game
loop. I see this to be akin to the
model-view-controller (MVC) pattern in software design (with the addition of
_init()
)
_init()
is called once. Sets up the data structures and initializes all variables._update()
is the Model. All compute, logic, and rules are defined here._draw()
is the View. Displays the information based from what was computed by the Model.
The Controller, in my opinion, is Pico-8’s standard library. It accepts keyboard or controller input, and transforms them into something understandable by the model.
Pointers to functions
One pattern that I often see in Pico-8 code is the use of function pointers. You can assign a function name to a variable, and call it in another line. For example:
function move_entity()
-- calls whatever function
-- is referenced into _mov
_mov()
end
function move_player()
if path_is_clear then
_mov = move_normal
elseif path_is_blocked then
_mov = move_with_bump
end
end
The move_entity
function just calls a generic _mov
, whatever its definition
will be. How _mov
behaves is manipulated by the move_player
function. Based
on which condition is true, _mov
can be move_normal
or move_with_bump
.
Because the game-loop always calls _update
for every tick, then the move
conditions are always checked.
This technique is often used on the built-in _update()
and _draw()
functions. My guess is that it makes the game-loop functions much cleaner.
My worry is that it may be a bit harder to track what’s happening, but perhaps
this technique lessens the tokens we’ll write in the end. In this regard, our
game-loop will look like this:
function _init()
-- all init functions
_upd=update_game
_drw=draw_game
end
function _update()
_upd()
end
function _draw()
_drw()
end
The _update
and _draw
functions will repeatedly call whatever _upd
and
_drw
references to respectively. Later on, we can tell the program that
_upd
must update the game environment (_update_game
) or update the player
(_update_pturn
). All this thanks to Lua’s ability to pass functions as
pointers.
Basic sprite movement
First, let’s introduce our sprite character, Picollino! The imagery of the word evokes a derpy tomato so we’ll stick with that:
Picollino’s movement will follow the style of an overworld adventure game rather than a platformer. Thus, we don’t need to factor in gravity or friction. In an overworld grid, our character’s position is defined by its tile-coordinates \(x\) and \(y\).
Token optimization aside, let’s define a function that initializes a player:
-- initialize a player at game start
-- @return player table with init values
function make_player()
local p = {
-- initial coordinates
x=32,
y=32,
-- animation attributes
flip=false,
anim={1,2,3,4}
}
return p
end
Movement is now a matter of changing these \(x\) and \(y\) coordinates in response to a button press. I learned that in Pico-8, this change is often denoted by \(dx\) and \(dy\) for the \(x\) and \(y\) coordinates respectively.
Thus, moving right is just \(x \Leftarrow x + dx\), and down is \(y \Leftarrow y + dy\) (yup it’s positive going down). Going to the opposite direction is achieved by inverting the signs.
I prefer writing a function, move_player(dx, dy)
, that takes in the
corresponding change in position. Then, it updates the player coordinates with
the new values. Ideally, I’d like to add a player
argument in this function
(a la move_player(p, dx, dy)
), but it costs alot of tokens so we have to make
do with global variables.
-- move_player updates player coordinates
-- @param dx change in the x-axis
-- @param dy change in the y-axis
function move_player(dx, dy)
-- we multiply by 8 because 1 tile = 8 pixels
-- moving a player on a single tile is actually a movement of 8 pixels
local destx, desty = p.x + dx * 8, p.y + dy * 8
p.x, p.y = destx, desty
end
For our button-press, I’d like to write two more functions. The first,
update_game()
, will be called every time to check for button presses and/or
resolve variables and computations. The second, exec_btn(b)
, takes in a
button’s numerical value and call the
handler function. Let’s write the first one:
-- update_game checks for button-presses and perform calculations
function update_game()
for i=0,5 do -- btn(5) is X, for opening menu?
if btnp(i) then
exec_btn(i)
end
end
end
For the second one, instead of writing if
statements for each directional
button, we can store \(dx\) and \(dy\) values in an array, and call them
whenever their respective button-number
is called.
-- exec_btn handles button calls
-- @param b is the button number from pico8.fandom.com/wiki/Btn
function exec_btn(b)
-- preload (L=0,R=1,U=2,D=3)
local dirx = {-1,1,0,0}
local diry = {0,0,-1,1}
if b>=0 and b<4 then
move_player(dirx[b+1], diry[b+1])
return
end
end
To explain dirx
and diry
, here’s an example: moving left requires a
negative offset in the x-coordinate (\(x_2 = x_1 + dx\) where \(dx=-1\)) while
\(y\) stays the same (\(y_2=y_1+dy\) where \(dy=0\)). Now, the left button
(←) is numerically-assigned in Pico-8 as button 0
. Hence, in
the zeroth-index of dirx
and diry
, we have -1
and 0
. Note that in Lua,
arrays start at 1, so we just add 1
when providing the index.
Awesome! With the functions above, we now have a rudimentary system for
movement. This works, but we just need to draw the sprite! In the _draw()
function, just call the spr()
function:
-- draw_game is a handler for all drawing events
function draw_game()
cls(0)
map()
spr(1, p.x, p.y, 1, 1, p.flip)
end
<iframe src="/assets/png/pico8-move/basic_movement.html" title="description" height="500" width="500" display="block"></iframe>
Basic four-directional movement. Press the arrow keys!
Smoother tile transition
Picollino’s movement still looks a bit jarring: he seems to “teleport” from one tile to another. This is because we are moving 8 pixels right away, a large distance in my opinion. I’ll improve animation by doing the following:
- Flip the sprite when moving to the opposite direction
- Add a “smoother” to ease the transition from one tile to the next.
- Sprite animation
Flip sprite based on direction
The first one is easy. Recall that in our make_player()
function, we have
a flip
attribute. We set that as true
when our sprite is moving to the left,
i.e. when dx < 0
. We’ll update the move_player()
function to account for
this change.
-- move_player updates player coordinates
-- @param dx change in the x-axis
-- @param dy change in the y-axis
function move_player(dx, dy)
local destx, desty = p.x + dx * 8, p.y + dy * 8
------------------NEW CODE---------------------
if dx < 0 then
p.flip = true
elseif dx > 0 then
p.flip = false
end
-----------------------------------------------
p.x, p.y = destx, desty
end
We use elseif
instead of else
so that the sprite won’t immediately
face right
if our last action was left
and we decide to move up
or
down
.
Tile transition
In order to understand LazyDev’s technique for smoother tile transition, let me illustrate an example. Suppose we want to move a sprite to the right from tile \(T(3,2)\) to \(T(4,2)\). In pixel coordinates, we are essentially “teleporting” from \(P(24,16)\) to \(P(32,16)\), a huge distance that doesn’t look like walking. Instead, we want to traverse the \(x\) axis in small increments, i.e. \(24, 24.8, 25.6, 26.4, \ldots\), until it reaches its destination.
We accomplish this by defining an offset that will incrementally move the sprite from its old coordinate to the new one, i.e. \(P_{x_2,y_2} = P_{x_1,y_1} + o_{x,y}\). The rate in which the offset updates is controlled by a new variable, \(p_t\), that dictates how fast the transition will be animated.
Because we want to save tokens, we precompute the destination and subtract the
offset as we go along. So in our earlier example, we will set \(P_{x,y}\) to
its new value, \(P(32,16)\), and perform subtraction when calling the spr()
function.
The offset \(o_{x,y}\) starts with a value \(s_{x,y}\) and grows smaller as it reaches \(0\):
\[o_{x,y} = s_{x,y} * (1 - p_t), s_{x,y} = -d_{x,y}\]where \(s_{x,y}\) is the start value for the offset. The transition speed, \(p_t\) is then defined and updated as:
\[p_t \Leftarrow min(p_t+\delta,1), p_0 = 0\]where \(\delta\) controls how fast it can be. Notice that it is clipped to \(1\). To be honest, I find it a bit convoluted, but I think these are optimizations to reduce token count, a very important resource in Pico-8.
Below is a table of values so you can see the relationship between \(p_t\), the
offset \(o_{x,y}\), and what is actually drawn or sent to the spr()
function.
Note that here, we’re moving in pixel coordinates from \(P(24,16)\) to
\(P(32,16)\), with \(\delta=0.1\) and \(s_x=-dx*8=-1*8=-8\). I’ll only list down
the values in the \(x\)-axis, because we only move in that direction:
\(p_t\), transition speed | 0.0 | 0.1 | 0.2 | 0.3 | 0.4 | 0.5 | 0.6 | … | 1.0 |
---|---|---|---|---|---|---|---|---|---|
\(o_x\), size of move-offset | -8 | -7.2 | -6.4 | -5.6 | -4.8 | -4.0 | -3.2 | … | 0 |
spr() , drawn on-screen |
24 | 24.8 | 25.6 | 26.4 | 27.2 | 28 | 28.8 | … | 32 |
To accomplish this, we define a function, update_pturn
, that signals the
start of a player turn. We time this turn by \(p_t\), that increments by
\(\delta\) until it reaches \(1\). Once \(p_t=1\), we remove control from the
player and update the game via update_game
:
function update_pturn()
delta=0.1
p_t = min(p_t+delta, 1) -- increment timer
if p_t == 1 then -- if timer is done...
-- ...end player turn and update game
_upd=update_game
end
end
Let’s initialize p_t
in our _init
function. It starts from \(0\) and it’s
capped at \(1\):
function _init()
------------------NEW CODE---------------------
p = make_player()
p_t=0
-----------------------------------------------
_upd=update_game
_drw=draw_game
end
We move during a player’s turn, so let’s add in a generic function, p_mov
.
Later on, we’ll pass function pointers to p_mov
so that we can control the
type of movement it will execute:
function update_pturn()
delta=0.1
p_t = min(p_t+delta, 1)
------------------NEW CODE---------------------
p_mov()
-----------------------------------------------
if p_t == 1 then
_upd=update_game
end
end
We just need to update the move_player
function so that all movement
initialization steps are defined here. Later on, we’ll implement mov_walk
to
define the incremental transition discussed in the table of values above:
function move_player(dx,dy)
-- remove multiplier, move it to draw
local destx, desty = p.x + dx, p.y + dy
if dx < 0 then
p.flip = true
elseif dx > 0 then
p.flip = false
end
------------------NEW CODE---------------------
-- move player by dx, dy [-1, 0, 1]
p.x += dx
p.y += dy
-- previous location after moving is just inverse
p.sox, p.soy = -dx * 8, -dy * 8
p.ox, p.oy = p.sox, p.soy
p_t = 0
-- update function pointers
_upd = update_pturn -- start player turn and...
p_mov = mov_walk -- ... move by walking
-----------------------------------------------
end
Now, mov_walk
performs the computation we had above. As p_t
increases from
\(0\Rightarrow 1\), the offsets p.ox
and p.oy
approach zero. We’ll also
update our draw_game
function to make use of our offsets:
function mov_walk()
p.ox=p.sox * (1-p_t)
p.oy=p.soy * (1-p_t)
end
function draw_game()
cls(0)
map()
spr(1, p.x*8+p.ox, p.y*8+p.oy, 1, 1, p.flip)
-- p.x, p.y are already in the new position (destination), but instead,
-- we draw its offset
end
Again, p.x
and p.y
are already in their new positions after moving
(destination). However, we don’t want to draw this position immediately. We
draw the new position plus offset that starts from -8
(i.e., the previous
position) until it reaches 0
(i.e., the new/current position). Let’s look at
the sprite movement, observe that it’s much smoother now!
<iframe src="/assets/png/pico8-move/offset_movement.html" title="description" height="500" width="500" display="block"></iframe>
Basic four-directional movement with offset. Press the arrow keys!
Sprite animation
Let’s just do a simple animation for Picollino:
In order for the program to cycle through these frames, we need to define a
“global” timer t
. Based on the timer value, the frame number will change.
We use a simple trick to cycle across frames in p.anim
: by mod-dividing the value
of t
by the length of our animation array, we can get all animation frames:
index = t%#ani + 1
So, even if we’re far ahead of the animation timer (say, t=2000+
), the index
of p.anim
will still be within range. We can control the transition speed by
dividing t
by some number. Let’s modify the equation above and show a table
of values for illustration:
function get_frame(ani)
rate=2 -- controls transition speed
return ani[flr(t/rate)%#ani+1]
end
t | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|---|---|
t%#ani+1 |
1 | 2 | 3 | 4 | 1 | 2 | 3 | 4 | 1 |
get_frame() |
1 | 1 | 2 | 2 | 3 | 3 | 4 | 4 | 1 |
Input buffering
Notice how the sprite only registers your move once its turn is done. This means that when you try to input two commands in a row, only one of them will be executed. It’s not good for game feel, thus, we can solve this quality-of-life issue by buffering the inputs.
The way input-buffering works is that we have a “buffer” that stores whatever
key we pressed while the player turn (i.e., update_pturn
) is still active.
The input stored in this buffer is then executed once update_game
is called.
Its basic form is:
if buffer==-1 then
buffer=get_btn()
end
exec_btn(buffer) -- only called in update_game
We just need to update our exec_btn
function to accommodate the case when
buffer is untouched (buffer==-1
):
-- exec_btn handles button calls
-- @param b is the button number from pico8.fandom.com/wiki/Btn
function exec_btn(b)
-- preload (L=0,R=1,U=2,D=3)
local dirx = {-1,1,0,0}
local diry = {0,0,-1,1}
------------------NEW CODE---------------------
if butt<0 then return end
-----------------------------------------------
if b>=0 and b<4 then
move_player(dirx[b+1], diry[b+1])
return
end
end
Lastly, we implement the get_btn
function:
function get_btn()
for i=0,5 do
if btnp(i) then -- if button is pressed...
return i -- return that
end
end
return -1 -- else, return buffer default val
end
We just put this buffer in both our update_game
and update_pturn
functions. Again, note that the execution of the input will only happen in
update_game
:
function update_game()
if buffer==-1 then
buffer=get_btn()
end
exec_btn(buffer) -- execution only happens here
buffer=-1
end
For update_pturn
, we just want to fill the buffer in case the player
decides to press something while the sprite is still moving:
function update_pturn()
if buffer==-1 then
buffer=get_btn()
end
--- other movement functions...
end
Et voilà! We now have input buffering! Feel the difference:
<iframe src="/assets/png/pico8-move/inputbuffer.html" title="description" height="500" width="500" display="block"></iframe>
Basic four-directional movement with offset and input-buffering. Press the arrow keys!
Conclusion
In this notebook, I learned alot about the basics of Pico-8 movement. Now I can move a sprite in four directions, perform smoother tile transitions, and improve game feel by buffering inputs. To be honest I still have a long way to go, but I think this is a good start!
I write these notes mostly for myself so I apologize in advance if there are any haphazard equations or buggy code in the post. If you wish to correct them, simply open-up a Pull Request so that I can update them immediately.