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?
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.
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.
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