Log In  

Psst, hey kid, you wanna make a megaman? I'm not supposed to tell anyone this, but check this out.

Two questions I often see asked by Pico-8 beginners are how to animate your player sprite, and how to make them shoot bullets. When setting up your game objects for tasks like this, tables can scare a lot of new Lua coders away, because it can feel easier to just use a bunch of variables. Don't fall into this trap though! It'll make the code a lot harder to maintain as your project grows, and doesn't work for things like bullets at all.

Tables are an easy way to organize objects that get created and die, update, draw to the screen at the same time, etc. Getting more confident with all of the features of Lua is the first step to improving as a programmer (check out the Lua manual for more info), but for now I'll just go through a simple and efficient table structure that will work for many game designs. Before we get into game related stuff though, let's talk about the basics of tables.

How to turn tables

Tables can do a huge variety of things in Lua, and they're very easy to set up. Just use curly braces to group a set of values together, and then those variables can be accessed and updated more easily as a group. The default table constructor stores values in a list that you access via table_name[number], like so:

tbl = {1,2,3,4,5}   --five number values are stored in the variable called tbl, a table
print(tbl[1])       --print the first number (1)
print(#tbl)         --print the length of the table (5, because there are 5 total values)

Tables are 1-indexed in Lua, which makes a lot of programmers (me) complain, because they're used to other programming languages where lists start at index zero. But for beginner programmers, this just means that the first default entry in a table is stored at [1]. The last value is therefore stored at the same index as the length of the table, so this works:

print(tbl[#tbl])    --will print the last number in the table (5)
tbl[#tbl+1] = 6     --adds a new value (6) to the end of the list. same as add(tbl,6)

The length operator (#) only counts the list-style table variables I used above, but this is not the only way that you can make good use of tables. Sometimes you don't want a list, you want an object.

obj = {x=5,y=16,w=4,h=4} --create an object with a position, width, and height
obj.x += 1               --add one to the object's x value, and update it (+= is add & set)
obj.jump = true          --create a new value in the table, and set it to something (true)
print(#obj)              --be careful, this will print 0! named variables aren't counted.

These tables are like objects, with named variables of their own that you access via the dot (.) operator. You can even put tables inside of other tables and chain dot accesses, which is a great way to organize things and re-use code. The names make it easier to remember what your variables are, but they mean the table isn't a list-type table anymore. It's an object.

However, obj.x and obj[1] can exist in the same table, because obj.x is actually secretly obj["x"] behind the scenes. Both are just values stored at a named position, but the key to access x is a string of text this time, instead of a number. Every time you use the square brackets, you are asking Lua if something currently exists at that location in the table. You either get a value back, or you get nil if nothing is there.

print(obj.x)                       --after the code above, this will print 6
print(obj["x"])                    --this will also print 6, it's just another way to look at x
if(obj.jump) print(obj.randomtypo) --this will print nil, since nothing exists at obj["randomtypo"]

There is nothing wrong with that last line of code, and Lua will happily run it without complaining at all. This is critical to understand. If something doesn't exist in a table, the value you get back is just nil, a special value that means "nothing". If you see errors mentioning nil, it probably means you made a typo on that line, or you accidentally deleted something in a table.

obj.x = nil           --uh oh, x doesn't exist anymore. you just deleted it
tbl[3] = nil          --very dangerous! the length of tbl might be 6 or 2 now...
deli(tbl,3)           --much safer, (#tbl) will always be 5 now

This is nice and all, but then how do you use tables for games? Let's go through a few practical examples for Pico-8, and a couple questions that get asked here about once a week.

Making bullets

Bullets are a simple kind of object with some special needs. They move every frame, and disappear after a while. You need to add new objects when the player shoots a new bullet, and remove them when they hit something or go off screen. Using tables, all of this is possible and easy:

objs = {}                    --a list of all the objects in the game (starts empty)
function objdraw(o)          --a basic function for drawing objects,
 spr(o.spr,o.x,o.y)            --as long as those objects have spr, x, and y values inside
function bulletupdate(b)     --a function for moving bullets a little bit at a time
 b.x += b.dx                 --x moves by the change in x every frame (dx)
 b.y += b.dy                 --y moves by the change in y every frame (dy)
 b.time -= 1                 --if bullets have existed for too long, erase them
 return b.time > 0           --returns true if still alive, false if it needs to be removed
function newbullet(x,y,w,h,dx,dy)--bullets have position x,y, width, height, and move dx,dy each frame
 local b = {                 --only use the b table inside this function, it's "local" to it
  x=x,y=y,dx=dx,dy=dy,       --the x=x means let b.x = the value stored in newbullet()'s x variable
  w=w,h=h,                   --b.w and b.h are also set to the function's w and h args
  time=60,                   --this is how long a bullet will last before disappearing
  update=bulletupdate,       --you can put functions in tables just like any other value
  spr=0,draw=objdraw         --bullets don't have special drawing code, so re-use a basic object draw
 add(objs,b)                 --now we can manage all bullets in a list
 return b                    --and if some are special, we can adjust them a bit outside of this function

function _draw()             --the game's draw function, only called 30 times/second when there's no lag
 for o in all(objs) do o:draw() end --o:draw() is the same as o.draw(o). this is useful here!
function _update()           --the game's update function, always called 30 times/second
 if(btnp(4)) newbullet(0,64,4,4,2,0)
 local i,j=1,1               --to properly support objects being deleted, can't use del() or deli()
 while(objs[i]) do           --if we used a for loop, adding new objects in object updates would break
  if objs[i]:update() then
   if(i!=j) objs[j]=objs[i] objs[i]=nil --shift objects if necessary
  else objs[i]=nil end       --remove objects that have died or timed out
  i+=1                       --go to the next object (including just added objects)

Copy and paste this code into Pico-8, and press Z/C to shoot!

This demonstrates two of the harder parts of creating bullets that work. For one thing, you need to have a list of bullets that changes as they get created and destroyed. For another, you can't use del()/deli() to remove them from the object table while you are updating the list of objects, because the position of all the table entries afterwards changes when you do that. You have to write custom while loop code that supports deleting objects correctly and efficiently.

This may seem complicated, and that's okay! One of the things that's important to understand about Pico-8 is that the code editor is not simple, and that gives you a lot of power to do interesting things. Lua is a fully functional programming language, it is not a reduced set of commands specifically for game design tasks. It is sometimes a lot harder to do simple things because of this, and you will have to learn Lua programming well to make anything except very basic games, but that is the audience that Pico-8 was designed for. Simple design constraints, but very complex and flexible code.

Anyway, we are using both kinds of tables in this example. Each bullet object is the second kind of table, with a group of named values (like b.x, b.y, etc). But maybe you also noticed that objs is the first kind of table, a sequence of values stored at numbered positions (objs[1], objs[2], etc). In general, this is the difference between an object-type table and a list-type table, so a list of objects would clearly need to use both.

Animating sprites

Let's tackle a trickier problem now, which is animating your object art. Since an animation is just a list of sprites displayed one at a time, and sprites are numbers in the spritesheet, we'll need a list-type table full of numbers for that. But some animations repeat, or play other animations when they are finished, which means they need more data than just a list of images to show. If only there was a special kind of object that could do both of these things at the same time...

This is what makes Lua really special. Since everything is a table, we can have a list-type table and an object-type table be the same thing, and solve both of these problems at the same time! Here's a chunk of Pico-8 code that supports framerates, looping animations, and automatic transitions between anims, and works with the object management from the previous code snippet too:

anims={                      --all character animations, described by name
 idle={fr=15,1,2},           --idle has a frame rate of 15, and loops between sprite #1 and #2 forever
 punch={fr=5,next="idle",17,18,19}, --a 3-image punch animation that returns to idle when done
 walkright={fr=5,4,3,4,5},   --you can reuse frames of art to save space. Walking 4,3,4,5,4,3,4,5,...
 --etc etc
function _init()
 player={ x=64,y=64,
  update=playerupdate,       --custom update function for the player object, described below _init
  spr=1,draw=objdraw }
 player.play="idle"          --start a new animation. this is all you need for the animate() function
 add(objs,player)            --bullets and the player are both updated and drawn together!
function playerupdate(p)
 if(p.state!="punch" and btnp(5)) p.play="punch" --you can only punch when not already punching
 animate(p)                  --you can animate any object just by setting play to something
 return true                 --still alive, so return true
function animate(p)
 if p.state != p.play then   --start a new animation
  p.state = p.play
  p.animindex = 1            --start with the first frame in the animation table
  p.time = 0                 --reset the timer
 elseif #anims[p.state] > 1 then --continue playing an animation with multiple frames
  p.time += 1
  if p.time >= anims[p.state].fr then --the current frame has been on screen for long enough
   p.time = 0
   p.animindex = (p.animindex % #anims[p.state]) + 1 --go to the next frame
   --this loops animations. "punch" becomes (current index % 3) + 1, so 1,2,3,1,2,3,1...
   if p.animindex == 1 and anims[p.state].next then --at the moment the animation restarts,
    p.play = anims[p.state].next                    --play something else instead
    p.state = p.play
 p.spr = anims[p.state][p.animindex] --lastly, update the current sprite number drawn to screen

Copy this code and paste it under the other snippet for bullets. Then press X to punch! (You'll have to draw some art though, or use my cartridge below.)

While this is a very simplified demonstration, hopefully you can see how powerful object management with tables is. You can play a new animation with one simple line of code anywhere, and add as many animations as you want to the anims table at the top. Want your enemies or bullets to animate too? Just add their animations to the same table, with slightly different names.

If you want bigger sprites, try making a new playerdraw() function that supports sprite width and sprite height, by adding new table variables to your player object. Something I've also done to save art for upwards walking animations is flipped the same image horizontally back and forth, and if you use the drawing function below instead, it's very easy. Just use negative numbers in the anims table now, they will draw the same image as the positive number, but flipped horizontally:

anims.walkup={fr=5,6,7,-6,-7} --you can add/change animations later if you want
function playerdraw(o)
 spr(abs(o.spr),o.x,o.y,o.sw,o.sh,o.spr<0) --abs() means the index drawn is always >= 0

It might make more sense to store your player's facing direction in your player object, too. Then you could flip horizontally-flipped animations via o.facing_left!=(o.spr<0), but that's a bit complicated to explain. Here's a cart with basic player movement and shooting, see if you can read through the code and understand the adjustments I made here.

Cart #shyguide-0 | 2021-09-20 | Code ▽ | Embed ▽ | No License

And one last tip for more advanced coders: you can unpack()/split() the animation frame numbers if you want to save on tokens. As long as unpack() doesn't have a comma after it, it will work just like a list of numbers would, for 4 total tokens no matter how long the animation is. (split"string" takes advantage of some weird Lua function calling semantics, but it still works. Read that Lua manual to find more tricks like this!)


Final words

This should get you started with the basics, but now you have to experiment! You can write new drawing or updating functions to add special animations or behaviors. You could check if a bullet hit something (with a rectangle check) and return false, to kill it before it times out. What if a bullet didn't move at all (dx=0, dy=0), and it just made a solid area of damage when it was created, like a fighting game hitbox? What if you made a bullet that created extra bullets while it animates? What if you wanted some objects to always be drawn on top of other objects, in a different draw layer?

These are all pretty simple adjustments to this basic object management system, which is why it's so great. Hopefully this guide helps some people organize their logic better, and makes complicated things like fancy animated bullets very easy to do.

But uh, don't tell anyone I told you this, kid. It's a secret that the old programmers have been hiding since 19XX. Definitely don't share this information with anybody else, or work together to make it better.

P#97572 2021-09-20 05:20 ( Edited 2021-10-02 18:54)


I am struggling a lot with animating my game, so this tutorial is going to be a huge help! This probably means rewriting a ton of the code I've written already (cries quietly in a corner)... Oh well!! It was stinky code anyway >:P

Thank you very much for posting this! :)

P#97712 2021-09-23 15:31

this is a great writeup! a couple of thoughts:

  • you can add/delete while iterating with 'for x in all(y)' in pico-8; it works fine. it's probably good to make people aware that it's a tricky thing, but 'all' mostly just takes care of it for you, and that's probably good to know too:
    for x in all(objs) do
     if not x:update() then
  • the code with unpack() fails for me ("runtime error"). I think you meant split() instead of unpack()?
  • this is another good resource for understanding how to animate sprites: https://mboffin.itch.io/pico8-simple-animation (it's the same code, presented in a neat interactive way)

thanks for writing this! I've felt a need for an explanation at pretty much exactly this level when trying to help people learn pico-8, but I haven't done the hard work of writing one myself. (and now I don't have to!)

P#98002 2021-09-29 19:46 ( Edited 2021-09-29 19:50)

Thanks @pancelor! I corrected the error with unpack, I just forgot to split the string first.

Regarding your for x in all(y) / del() code, it actually doesn't work for this purpose. Check out all's function declaration in the header code for more info, though the wiki is a bit out of date on how all() is implemented: https://pico-8.fandom.com/wiki/Header

Basically, yes, all() will handle a deleted object semi-safely. But the del/deli functions also shift everything that follows one table index back every time you call them, which is very inefficient if it happens more than once! all() is also smart enough to avoid skipping the next object if you del/deli just one object in the table, but not smart enough if you delete 2+ objects simultaneously. It's designed to fail gracefully on beginner mistakes, but that actually makes it harder to debug when things go wrong.

all() is a safe-ish iterator implementation that kind of works on lists that change (but quite inefficiently, due to the many checks and search steps behind the scenes). Personally, I wouldn't recommend using it outside of tweetcarts. My more basic iterator code above should be failproof by intentionally avoiding the use of del/deli on the object list at all, to prevent all of these common crashing and double-updating bugs. For Lua beginners, I think that's the way to go for basic object management.

But thank you for posting this, hopefully this explains why I chose not to use those functions. I probably should have mentioned some of this in the post itself.

P#98134 2021-10-02 19:48 ( Edited 2021-10-02 20:15)

oh! thanks for the info; I had thought that was safe to do. I'm glad to learn this here instead of later, tearing my hair out debugging!

here's a simple cart I made to demonstrate what you just told me (I made it to remind my future self about how exactly this can fail, but I'm posting it here too in case it helps others understand)

Cart #heyezibina-0 | 2021-10-03 | Code ▽ | Embed ▽ | License: CC4-BY-NC-SA

P#98145 2021-10-03 07:40

Excellent example of the problem! So yeah, in case it wasn't obvious, the way I'd do a bomb object's update function in this system is to just set obj.hp = 0 on all of the objects that got hit by it, then return self.hp > 0 in every destructable object's update function. It might be a frame later before the already-updated objects get removed, but they'll always have all the variables the code under the update loop expects them to have.

I can tell you right now that this nil crash bug has snuck past a number of experienced developers with published games here, because it only happens rarely and it's hard to debug because of that. Those are the kind of bugs that are truly terrible to test and verify a fix for, and it's unfortunately a consequence of all()'s error correction code.

P#98161 2021-10-03 13:27

Wow. This really saved my arse in getting the animation framework of the game I am working on nailed down. Thanks a ton @shy!

P#140627 2024-01-26 00:22

[Please log in to post a comment]

Follow Lexaloffle:          
Generated 2024-02-25 05:45:57 | 0.035s | Q:30