Adam Richardson's Site

Invaders in PICO-8

Table of Contents

invaders_0.gif

Figure 1: Invaders game play gif

Introduction

As a fan of classic arcades and programming I have always wanted to recreate some of the early simple arcade games. Space invaders is one of the oldest and most iconic games arcade games. There were many clones made for the arcade over the years and it is frequently an early project for game developers. For me Space Invaders is the essence of action games; easy to understand and difficult to master. I also really like the use of sound and how as the enemies move faster the audio increases and it ramps up the stress of the player, thus making the game more difficult. I have started coding this game a couple of times and abandoned for various reasons. Since I have been building a momentum finishing PICO-8 projects, I wanted to do a more complete version of this game.

invaders_1.gif

Figure 2: First attempt at invaders in PICO-8 gameplay gif

First Attempt Pico 8 #1

My first attempt to code Space Invaders was in PICO-8. I worked on this for a few weeks late last year but I abandoned it when it wasn’t turning out the way I wanted. When working on this version I wanted to make it as simple as possible so I opted to use 8x8 sprites rather than the accurate space invaders sprites. As a side effect of this I think everything looks a little funky and I think this contributed to me ultimately abandoning this version. I also thought I would modernize the game a bit; I use smooth scrolling for the invaders instead of step movement. The downside of this is it makes the invaders look like they are skating at you rather than marching. Worst of all was the performance. This game didn’t have that much going on and in PICO-8 I could tell it was struggling to hit 60fps. I didn’t investigate it at the time, but I since learned why. The CLS command in PICO-8 is very expensive on the CPU. Each frame I was clearing the screen and redrawing all of the invaders. This is definitely necessary if I wanted to keep the smooth movement. The poor performance lead me to try to finish this project outside of PICO-8.

canvas_invaders.gif

Figure 3: Second attempt at invaders in JavaScript/HTML5 canvas gameplay gif

Second Attempt Canvas

My second attempt was in JavaScript using canvas. I was interested in using this setup as a new PICO-8 style environment. My hope was to remove the CPU limitations of PICO-8 as well as switch away from Lua to a more familiar language. This all worked out and I was able to create a more complete version with better performance. I didn’t have much drive to complete it though since it carried a lot of the early decisions (8x8 sprites, smooth scrolling) I had made from the PICO-8 version. I did enjoy learning how sound synthesis works in JavaScript. I found it straight forward to make square wave style sounds in the browser. I stopped working on this version to pursue other side projects I was more interested in and I wouldn’t attempt to make a Space Invaders clone again until about two weeks ago.

This attempt Pico 8 #2

My renewed interest in PICO-8 motivated me to create a more complete version of Space Invaders. This time I took inspiration from the Gameboy port and I felt that it has to be possible to make a better performing version. I started by recreating the sprites exactly how they were in the original game. I felt their iconic designs add a lot of character. Additionally, as I researched the scoring of the game I noticed that the larger enemies are worth fewer points since they are easier to hit. The biggest aha moment for me when making this version was to only clear the screen rectangles there were being updated. A side effect of this approach is that the state of the frame buffer is not centrally managed but the performance gain in PICO-8 was massive. From what I can gather the CLS command basically does a fillrect for the entire 128x128 screen. The cost of this is 2048 cycles. Comparatively if I were to only clear a 16x16 rect it would only be 4 cycles. This performance boost gave me the motivation to stick with this port and try to implement as most of the game rules in PICO-8. Another cool side effect of not clearing the entire frame buffer each frame is you can use it for game logic. So the bullet collision detection uses the color of pixels adjacent to the bullet to know if it has collided with something. There is a good reason why the shields are blue!

Conclusion

All in all I am very happy with this version. I feel that I have implemented a fairly close version of Space Invaders and I can move on to porting other arcade classics. That being said, this version is not perfect. One of the things I am going to try to do better in my next game project is handling state. It is tedious to create two globals for each time related object. I am starting to get more familiar with Lua and I believe there are some language features that should help make this easier. Also, I didn’t do any blast damage on the shields. So when a bullet collides it removes the square that it collides with rather than exploding a chunk of the shield off. There is also a bug where enemy bullets take two squares of shield away when player bullets only take one. Checkout the code here (also listed below) and play in your browser here.

pico-8 cartridge // http://www.pico-8.com
version 27
__lua__
-- INVADERS
-- BY ADAM RICHARDSON
player={
   x=0,
   y=120,
   w=11,
   h=8}
score=0
lives=2
ufo={x=-20,y=10,w=16,h=8}
enemy_ticks=10
eticks=0
enemies={}
pb={
   x=0,
   y=120,
   w=1,
   h=4,
   speed=2,
   active=false}
animations={}
erow=3
edirs={1,1,1,1,1}
edrops={0,0,0,0,0}
drop=2
dcount=0
shields={
   {
      x=8,
      y=100,
      w=16,
      h=16,
   },
   {
      x=40,
      y=100,
      w=16,
      h=16,
   },
   {
      x=72,
      y=100,
      w=16,
      h=16,
   },
   {
      x=104,
      y=100,
      w=16,
      h=16,
   },
}
eb={
   x=0,
   y=0,
   w=1,
   h=4,
   speed=2,
   active=false
}
gameover=false
round=1
round_ticks=100
rticks=0
playing=false
dying=false
die_ticks=40
dticks=0
ufo_ticks=500
uticks=0
esfx=0
-->8
function clear_rect(r)
   local w=r.w
   -- prevent the bullet from being too wide
   if w==1 then w=0 end
   rectfill(
      r.x,
      r.y,
      r.x+w,
      r.y+r.h,0)
end

function clear_to_shoot(idx)
   local a=enemies[idx]
   for i=idx+1,#enemies do
      local b={
         x=enemies[i].x,
         y=a.y,
         w=enemies[i].w,
         h=enemies[i].h
      }

      if box_collide(a,b) then
         return false
      end
   end

   return true
end

function enemy_shoot()
   if eb.active then
      return
   end

   local idx=flr(rnd(min(6,#enemies)))
   local e=enemies[#enemies-idx]
   if clear_to_shoot(#enemies-idx) then
      eb.active=true
      eb.x=e.x+5
      eb.y=e.y+8
   end
end

function update_enemies()
   local next_spr={
      e=5,
      f=4,
      a=2,
      c=0,
      g=8,
      i=6
   }
   local end_reached=false
   local edidx=0
   for e in all(enemies) do
      if e.row>erow then break end

      if e.row==erow then
         clear_rect(e)
         e.x+=edirs[e.didx]
         e.s=next_spr[chr(ord("a")+e.s)]
         spr(e.s,e.x,e.y,e.sw,e.sh)
         if e.x+e.w>127 or e.x<1 then
            end_reached=true
            edidx=e.didx
            edrops[edidx]+=1
         end
      end
   end

   if end_reached then
      if edrops[edidx]>dcount then
         dcount+=1
         for e in all(enemies) do
            clear_rect(e)
            e.y+=drop
            spr(e.s,e.x,e.y,e.sw,e.sh)
         end
      end
      local v=edirs[edidx]
      edirs[edidx]=-1*v
   end

   erow-=1
   if erow<0 then erow=3 end

   if erow==3 then
      enemy_shoot()
      sfx(esfx)
      if esfx==0 then
         esfx=1
      else
         esfx=0
      end
   end
end

function box_collide(r1,r2)
   return (r1.x < r2.x+r2.w) and
      (r1.x+r1.w > r2.x) and
      (r1.y < r2.y+r2.h) and
      (r1.y+r1.h > r2.y)
end

function explosion(x,y)
   add(animations,{
          states={
             {
                s=12,
                eframe=9
             },
             {
                s=14,
                eframe=16
             }
          },
          frame=0,
          tframes=16,
          state_idx=1,
          x=x,
          y=y,
          w=12,
          h=8,
          sw=2,
          sh=1,
   })
end

function animate()
   for a in all(animations) do
      clear_rect(a)
      if a.frame < a.tframes then
         a.frame+=1
         if a.frame < a.tframes then
            local eframe = a.states[a.state_idx].eframe
            if a.frame==eframe then
               a.state_idx+=1
            end
         end
         spr(
            a.states[a.state_idx].s,
            a.x,
            a.y,
            a.sw,a.sh)
      else
         del(animations,a)
      end
   end
end

function kill_enemies()
   for e in all(enemies) do
      if box_collide(e,pb) then
         clear_rect(e)
         del(enemies,e)
         explosion(e.x,e.y)
         if #enemies%3==0 then
            enemy_ticks-=2
         end
         score+=e.score
         sfx(3)
         return true
      end
   end

   return false
end

function kill_ufo()
   if box_collide(pb,ufo) then
      clear_rect(ufo)
      explosion(ufo.x,ufo.y)
      ufo.x=-20
      score+=100
      sfx(5)
      return true
   end

   return false
end

function shield_player()
   for s in all(shields) do
      if box_collide(pb,s) then
         if pget(pb.x,pb.y-1)==12 then
            pset(pb.x,pb.y-1,0)
            return true
         end
      end
   end
   return false
end

function shield_enemy()
   for s in all(shields) do
      if box_collide(eb,s) then
         if pget(eb.x,eb.y+eb.h+1)==12 then
            pset(eb.x,eb.y+eb.h+1,0)
            return true
         end
      end
   end
   return false
end

function kill_player()
   if box_collide(eb,player) and
      eb.active then
      clear_rect(player)
      explosion(player.x,player.y)
      lives-=1
      gameover=lives<0
      if gameover then
         lives=0
      end
      dying=true
      dticks=0
      clear_rect(pb)
      pb.active=false
      sfx(4)
      return true
   end
   return false
end

function round_start()
   cls()
   print("round "..round,51,60,7)
   rticks=0
   playing=false
end

function round_play()
   edirs={1,1,1,1,1}
   edrops={0,0,0,0,0}
   dcount=0
   animations={}
   ufo={x=-20,y=10,w=16,h=8}
   eticks=0
   player={x=0,y=120,w=11,h=8}
   enemy_ticks=10
   cls()
   -- ufo
   spr(32,ufo.x,ufo.y,2,1)

   -- enemies
   for i=0,6 do
      local x=i*16
      spr(4,x+2,20)
      add(enemies,{
             x=x+2,
             y=20,
             w=8,
             h=8,
             s=4,
             sw=1,
             sh=1,
             didx=1,
             row=0,
             score=30
      })
   end

   for i=0,6 do
      local x=i*16
      spr(0,x+1,35,2,1)
      add(enemies,{
             x=x+1,
             y=35,
             w=11,
             h=8,
             s=0,
             sw=2,
             sh=1,
             didx=2,
             row=1,
             score=20
      })
   end

   for i=0,6 do
      local x=i*16
      spr(6,x,50,2,1)
      add(enemies,{
             x=x,
             y=50,
             w=12,
             h=8,
             s=6,
             sw=2,
             sh=1,
             didx=4,
             row=3,
             score=10
      })
   end

   -- player
   spr(10,player.x,player.y,2,1)

   -- sheilds
   for i=0,3 do
      spr(34,8+i*32,100,2,2)
   end
end
-->8
function _init()
   round_start()
end


function _update60()
   if gameover then
      animate()
      return
   end

   if not playing and
      rticks<round_ticks then
      rticks+=1
      if rticks==round_ticks then
         playing=true
         round_play()
      end
      return
   end

   if dying
      and dticks<die_ticks then
      dticks+=1
      if dticks==die_ticks then
         dying=false
         player.x=0
         spr(10,player.x,player.y,2,1)
      end
      animate()
      return
   end

   eticks+=1
   if eticks>=enemy_ticks then
      eticks=0
      update_enemies()
   end

   if btn(0) then
      clear_rect(player)
      player.x-=1
      player.x=max(0,player.x)
      spr(10,player.x,player.y,2,1)
   end

   if btn(1) then
      clear_rect(player)
      player.x+=1
      player.x=min(128-player.w,player.x)
      spr(10,player.x,player.y,2,1)
   end


   if uticks==ufo_ticks then
      clear_rect(ufo)
      ufo.x+=1
      spr(32,ufo.x,ufo.y,2,1)
      if ufo.x>128 then
         ufo.x=-20
         uticks=0
      end
   else
      uticks+=1
   end

   if pb.active then
      clear_rect(pb)
      pb.y-=pb.speed
      rectfill(
         pb.x,
         pb.y,
         pb.x,
         pb.y+pb.h,11)
      if pb.y<=10 then
         clear_rect(pb)
         pb.active=false
      end

      if kill_enemies() then
         clear_rect(pb)
         pb.active=false
      else
         if kill_ufo() then
            clear_rect(pb)
            pb.active=false
         else
            if shield_player() then
               clear_rect(pb)
               pb.active=false
            end
         end
      end
   end

   if btnp(5) and not pb.active then
      pb.active=true
      pb.y=115
      pb.x=player.x+5
      sfx(2)
   end

   if eb.active then
      clear_rect(eb)
      eb.y+=eb.speed
      rectfill(
         eb.x,
         eb.y,
         eb.x,
         eb.y+eb.h,7)
      if shield_enemy() then
         clear_rect(eb)
         eb.active=false
      else
         if kill_player() then
            clear_rect(eb)
            eb.active=false
            -- check lives
         end
      end

      if eb.y>128 then
         clear_rect(eb)
         eb.active=false
      end
   end

   animate()
   if #enemies==0 then
      round+=1
      round_start()
   end
end

function _draw()
   rectfill(0,0,128,10,0)
   -- info
   print("score "..score,45,2,7)
   spr(10,104,0,2,1)
   print("="..lives,116,2,7)

   --   print(stat(1),0,2,7)

   if gameover then
      print("game over",50,50,8)
   end
end