Make 8-bit sprites and animation in PICO-8

By Lucy Hattersley. Posted

Develop your pixel-art prowess and learn how to animate sprites with PICO-8. We’ll throw in some cool space explosions along the way

With a refreshingly simple set of tools, PICO-8 is the perfect place to plot pixels. Sprites – 2D images composed with pixels – have been a mainstay of game development since, well, forever, and have seen a recent resurgence due to the rise of pixel‑art indie titles like Celeste, Spelunky, and Stardew Valley. We’ll be taking a look at how to animate effective 8-bit sprites for our space shooter, how to use a sprite sheet for animation, some basic background ‘parallax’ scrolling, and some simple space explosions for good measure.

See also

Build a Retro game with PICO-8 for Raspberry Pi

PICO-8 for Raspberry Pi starter guide

PICO-8 Sprites and animation: Tools of the trade

If you’ve been following our PICO-8 tutorials from the start, you’ll already be acquainted with PICO-8’s small, but mighty, sprite editor. Load up your game, then switch to the editor to look more closely at what we’ll be working with (Figure 1). The big box on the top-left is your sprite window where you can plot pixels. The tool set below allows you to draw, copy, select, pan, and fill. And to the right you have your colour selector, brush size, and zoom sliders, as well as sprite flags. What more could you need?

 Figure From left to right: draw, stamp, select, pan, and fill. A simple set of tools for some tasty pixel art

A pixel artist’s palette

Part of PICO-8’s popularity is its striking 8-bit palette. Creator Joseph ‘Zep’ White spent a long time choosing a set of 16 complementary colours that offer a wide range of shades and tones. See the palette image (Figure 2) for a breakdown of how they can be combined. But a classic approach is to choose a primary colour and use a lighter complementary colour to show highlighting, and a darker shade for shadow. To demonstrate, we’ve highlighted the grey (colour 6) of our space fighter, with white (7) on the tips. You can take this approach with all of your sprites.

 Figure 2 PICO-8’s distinctive 16-colour palette offers a surprisingly versatile range of shades

Sprites, sheets, and animation

So how do we animate? The easiest way is to draw a new sprite for each animation frame and store them in a sprite sheet. Then, in runtime, we swap through these different sprites to make an object appear to come alive. The sprite sheet, shown at the bottom of the screen (see Figure 1), indexes each sprite on the sheet with a number. Let’s start with the bad guys. Next to your original enemy sprite, draw a few more to show it at various stages of jiggling menacingly. Don’t worry too much about smoothness as we can always edit them later.

 Create a new sprite for each frame in an animation to make your enemies come alive!

They live!

To implement the animation, we will need to add a few things to our code. First of all, in the create_enemy() function we need to add a new table to our enemies that stores all the sprite indexes for their gruesome animation. Add

enemy.sprites={2,18,2,34}

We will be moving through this table from left to right at set intervals. To keep track of this we will need a timer: add enemy.animtimer=0 to the function as well. Now, we need to actually tell it to change the enemy sprites along with the timer.

Animation: It’s all in the timing

In the main _draw() function, find the enemy loop and at the start of it add enemy.animtimer+=1 to increment each frame. Below this, add:

enemy.sprite = enemy.sprites[flr(enemy.animtimer/5-enemy.speed*3)%#enemy.sprites+1]

This looks complicated but really just compares the animation timer to the number of sprites in our sprites table and moves us along one. The inclusion of the speed variable adds a little bit of flavour that makes faster-moving enemies animate faster. Run your game and check it out in action.

Flickering fire

We can repeat this process for the player’s ship, too. Draw another sprite which shows the rocket engines flaring or flickering next to the original player sprite. Even just a couple of pixels different between frames is enough to make a sprite come alive. In _init() where we declare our player table, you’ll need to add another animation timer and table of sprites, just as we did for the enemy. We’ll also increment this timer in the draw function and add the line

player.sprite = player.sprites[player.animtimer%#player.sprites+1] below this to make a fast flicker.

Soaring through space

That’s made our ship look a little better, but it still looks static. Let’s animate it banking left or right when it moves. Draw a sprite of the ship banking left and one banking right. You can copy and paste your original to act as a starting point. Then copy these sprites and animate the tail flicker for each. Now we add a conditional to our _update() function that will swap our sprites table depending on if the up or down direction keys are pressed. Now our little space fighter will soar majestically through space.

Things that go boom

Currently our lasers are a puny red rectangle, but we can do better than that. Create a deadly-looking laser sprite in index 16 and replace our previous rect() function call with:

spr(16, laser.x-5,laser.y)

That’s a clear improvement, but something is still missing: you guessed it, explosions. To create dynamic-looking impacts, we will be writing two functions: one to create them, and the other to draw them as flash of circles. Very nice. We will also declare a new explosions table in _init() and write a for loop to handle drawing.

Pyrotechnics in PICO-8

Our createexplosion function creates, you guessed it, explosions. Much in the same way as we’ve created enemies and lasers previously. Our drawexplosion function draws a circle depending on what stage the explosion’s timer is at, then deletes it when it reaches 4. To see it work, add create_explosion(enemy.x,enemy.y,rnd(4)+8) just after we delete enemies upon collision with a laser. Using a random number for explosion radius gives us a little more flavour by varying the size of the explosions slightly. You should add a big explosion when the player is destroyed, too.

Parallax to the max

Parallax scrolling is an easy and effective way of adding depth and movement to a background by scrolling things at different speeds. Let’s make a starfield to give the feeling our plucky starship pilot is in hyperdrive. Create a new stars table in init() and populate it with stars using a for loop. Next, at the start of draw(), add rectfill(0,0,128,128,1) to colour the screen deep space blue, and another for loop that draws each star as a single pixel, moves it from right to left, and resets it when it goes off screen. Warp factor 4!

A simple shader

‘Shader‘ is a term used to describe a graphical treatment given to a rendering of a sprite or other asset. They are used extensively in game development, often to achieve a specific aesthetic style. Now we have a background, our sprites don’t stand out as well as they did. Let’s write a shader function outline_sprite() that draws a sprite offset in eight directions in black, using pal() to reset the palette, then the original colour sprite on top. Now, replace the player and enemy spr() calls, and see how a simple shader can make them pop!

Next PICO-8 steps: sound

Our game looks good. We have both time-based and movement-based animation. We have lasers and explosions. We have a scrolling starfield and a simple shader so our sprites stand out. However, our game will always feel lifeless without sound. So, in issue 87 of The MagPi we will be making some spacey SFX to bring our shooter to life, and we’ll be composing some 8-bit chiptunes. See you there!

Download the Space Shooter code from GitHub

--new code reference for space shooter
--see full project in github for full context

--within _init()
player.sprites={1,17} --player sprite table
player.animtimer=0 --player animation timer
explosions={} --explosions table
stars = {} -- background stars table
for i=0,24 do -- populate starts table with 24 stars
    add(stars,{
        x=rnd(128),
        y=rnd(128),
        speed=rnd(10)+1
    })
end

--within _update()
if btn(2) and not btn(3) then --banking left
    player.sprites = {33,49}
elseif btn(3) and not btn(2)then -- banking right
    player.sprites = {32,48}
else -- flying straight
    player.sprites = {1,17}
end

--within _draw()
for enemy in all(enemies) do
    enemy.animtimer+=1 -- increment enemy animation timer
    --assign sprite to be drawn depending on enemy speed
    enemy.sprite = enemy.sprites[flr(
enemy.animtimer/5-enemy.speed*3)%#enemy.sprites+1]
    outline_spr(enemy.sprite,enemy.x,enemy.y)
end


rectfill(0,0,128,128,1) --draw background
for star in all(stars)do
    star.x -= star.speed --move star left
    pset(star.x,star.y,7) --draw star as white dot
    if star.x < 0 then --if off screen then reset
        star.x = 128
        star.y=rnd(128)
    end
end


--within create_enemy()
enemy.sprites={2,18,2,34} --sprite set
enemy.animtimer=0 -- timer for animations

--new functions
function create_explosion(x,y,radius)
 local explosion={
  x=x,
  y=y,
  radius=radius,
  timer=0,
 }
 add(explosions,explosion)
end

--draw explosions
function draw_explosion(explosion)
 if explosion.timer<2 then -- white filled circle
  circfill(explosion.x,explosion.y,explosion.radius,7)
 elseif explosion.timer<4 then -- red filled circle 
  circfill(explosion.x,explosion.y,
explosion.radius,8)
 elseif explosion.timer<5 then -- organge circle
  circ(explosion.x,explosion.y,explosion.radius,9)
  del(explosions,explosion) -- delete
  return
 end
 explosion.timer+=1
end

function outline_spr(sprite,x,y)
 for i=1,15 do --set all colours to black
  pal(i,0)
 end
 for xoffset=-1,1 do --draw sprites offset by
  for yoffset=-1,1 do --1 pixel in each direction
   spr(sprite,x+xoffset,y+yoffset)
  end
 end
 pal() --reset palette back to normal
 spr(sprite,x,y)  --draw main sprite
end

 

From The MagPi store

Subscribe

Subscribe to the newsletter

Get every issue delivered directly to your inbox and keep up to date with the latest news, offers, events, and more.