Table of Contents
Introduction
Asteroids, the space shooter game originally developed by Atari in 1979, has an iconic visual style and gameplay that has seen many revisions and spawned an entire genre of video games under the top-down shooter moniker. Due to its simple design and appeal, it is appealing to programmers to recreate the classic game in an attempt to get a better understanding of game making principles, including vector graphics, velocity, and handling state including from the new game screen, the gameplay, and a game over screen along with the in-game state of having multiple objects moving around and interacting with each other through collision detection.
In this article, I will be going over my implementation of the classic Asteroids game and detailing the things that I learned along the way, including tidbits regarding the Pico-8 game engine that I used to do it.
Environment Setup
First thing is first, I needed to get my tooling set up in a way that would make the game development easier than working within Pico-8. While the game engine does provide an editor, it lacks a lot of features that would make programming easier, including syntax highlighting, warnings and error notifications, and smart indent/outdent along with a host of macros.
Moving to Atom
During the time that I was working on this, Atom had a vibrant community and included the necessary configurations to make Pico-8 development easier. This IDE made single-file editing a breeze and even came with a theme to make the IDE appear Pico-8-like if that was the developer’s choice. There was also a plugin available so that the IDE would recognize the underlying helper Lua functions and global variables that the engine provided.
I had even done a write-up where others weighed in on the configuration that I had done, adding to the conversation with me updating my post to include other insights that the community had included which helped me further hone my IDE.
Auto-update and play
One thing that the Pico-8 IDE allowed for that was hard to get working outside of it was a macro so that the file could be saved and opened in Pico-8 to begin play testing. Through some configuration changes, I was able to get Atom to have this feature as well and bound it to Ctrl + F5 like one would normally for running a currently open project.
Solution Design
During this time, I was much less focused on solutioning. I went into it knowing that there were parts that I was going to need to add and then sought to add them in chunks as I finished them. The main parts that I sought to add were a spaceship that the user controlled with the left/right rotation and the ability to increase velocity with the acceleration button, randomly generated asteroids, and the ability to fire one’s weapon and have it destroy asteroids that it came in contact with which would crease pieces of itself until it eventually disintegrated.
Having done a few Pico-8 games in the past, I was aware that I was going to need to use both the Update and Draw functions in order to both accept user input and have the game state reflected on the screen.
Implementation
Creating the player’s spaceship
First thing is first, the player needs to be able to take control of a spaceship in order to destroy asteroids. I felt like this was as good a place as any to start with. I first went with creating my three-line spaceship and set it so that it was facing up. I would then use 360 degrees as a method of having it rotate in place.
Little did I know ahead of time that this was going to involve calculus in order to do the rotation of the spaceship. I was trying to reinvent the wheel myself while not having the underlying math necessary to really be able to do what it was that I was setting out to do. I had tried a number of things to see if I could challenge myself, and I made some funny mistakes that really got me excited about the possibility of doing this sort of math for vector-based graphics. In one instance, I had the spaceship rotating and warping in and out of view as though it was traveling across the face of an invisible ball.
For my article related to rotation, click here
Adding asteroids
Asteroids are added based on the level number. Additional asteroids are given for higher levels with a maximum of four big ones being given at high enough levels. This is due to the small screen real estate and something that could and very likely should be dialed in further, even with smaller asteroids thrown into the mix.
Handling collisions
While it will cause collisions when not strictly correct, I am using circle/circle collision detection. What this means is that, the spaceship is touching an asteroid if it’s the case that the midpoints of the two objects are close enough that their radius’ are overlapping.
This works a lot better for things that are more circular, such as the shots that the ship fires.
Handling movement
Velocity was a fun concept to try to deal with. I found a good article that went over how to handle it, but the basic concept is that, for each item that the developer wishes to have move, it needs to have both an X and Y velocity amount that is either positive or negative that reflects how fast the given item is moving in that direction. During the game’s update function, that amount is either increased, decreased, or remains the same based on the rules of the object and whether anything is acting upon it (think the player’s input of the velocity button increasing the velocity of the ship in the direction it is pointing.)
Thankfully for me, while I was working on things, I came to the realization that the bit of code that was handling the collision detection as well as the movement were both generalizable to all of the objects in the game. Each thing, including the graphical bits of the asteroids and spaceship that are generated on a collision, are all moved using the same code!
For my article related to velocity, click here.
Setbacks
Rotation
As noted in my article on rotation, there were some quirks in the Pico-8 engine which caused the actual algorithm that I am using to be different than the standard one that would generally be used. The one that I am using appears to be a truncated one due to the quirky behavior of the cos and sin functions, so what I did is likely not reproducible in other engines without using what would instead be considered the correct algorithm.
Engine limitations
Screen size
This is probably the most irritating of the setbacks that were faced. The limitation on the number of pixels that are given is a huge issue for this type of game specifically. It seems like 128x128 is just not enough to really give the player breathing room between them and the asteroids that are coming, especially at some of the higher velocities that I currently have them set at.
If I was going to do this game over again, I would want probably at least 600x400, which would probably give me enough to double the size of the objects in the game and still give the necessary breathing room and even add in the alien ships.
Lack of built-ins
The engine purposely gives a very small number of tools and allows the user to remake a lot of things by hand. This, coupled with the fact that there is a code size limitation, causes the developer to make choices in terms of what can be done.
The issues that I ran into are trying to find the correct algorithms for things like collision, rotation, velocity, etc. Also trying to keep data in a sane way for all of the objects and access them.
Lua
The engine uses the Lua language. The language itself has very little tools included purposefully in order to keep the language size down. Its syntax is also not the most easy to read either. It definitely felt like more of a hindrance than something that makes the engine shine. I feel like I would have had an easier time if I were working with Python for instance where I would have had classes and things, maybe even multiple file support.
Code
-- asteroids
-- by vlek
tick = 1
blink = true
explosion_colors = {6,7,12}
game = {}
player = {}
laser_shots = {}
asteroids = {}
explosions = {}
brighter = {1,5,14,11,9,13,7,7,14,10,7,7,7,6,15,7}
function line(x1,y1,x2,y2,c)
dx=x2-x1
dy=y2-y1
len= sqrt(dx*dx+dy*dy)
if len != 0 then
for f=0,len,0.3 do
x=x1+dx*f/len+0.5
y=y1+dy*f/len+0.5
pset(x,y,brighter[pget(x,y)+1])
end
end
end
function _init()
game_menu()
end
function _draw()
game.draw()
end
function _update()
game.update()
end
function game_menu()
generate_asteroids(8)
game.update = menu_update
game.draw = draw_menu
end
function menu_update()
for i=0,5,1 do
if btnp(i) then new_game() end
end
for asteroid in all(asteroids) do
do_move(asteroid)
end
do_tick()
end
function draw_menu()
cls()
rectfill(0,0,128,128,1)
draw_asteroids()
centerprint('asteroids',64,36)
if blink then centerprint('press any button to start',64,94) end
end
function new_game()
player = {}
player.x = 64
player.y = 64
player.vx = 0
player.vy = 0
player.thrust = 0.1
player.max_thrust = 4
player.facing_angle = 275
player.score1 = 0
player.score2 = 0
player.lives = 4
player.level = 0
player.level_display_counter = 0
player.ticks_until_next_level = 60
player.isdead = false
player.ticks_until_respawn = 90
player.isinvulnerable = false
player.invulnerability_counter = 60
laser_shots = {}
asteroids = {}
explosions = {}
game.update = game_update
game.draw = game_draw
end
function game_update()
for explosion in all(explosions) do
if explosion.steps < 10 then
explosion.steps += 1
for bit in all(explosion.bits) do
do_move(bit)
end
else
del(explosions, explosion)
end
end
for laser_beam in all(laser_shots) do
-- check for collisions with asteroids
local collided = false
for asteroid in all(asteroids) do
if check_collision(laser_beam.x,laser_beam.y,asteroid.x,asteroid.y,2,asteroid.collision_size) then
collided = true
asteroid_collision(asteroid)
break
end
end
if collided or laser_beam.movement_count > 29 then
del(laser_shots, laser_beam)
else
do_move(laser_beam)
laser_beam.movement_count += 1
end
end
if #asteroids > 0 then
for asteroid in all(asteroids) do
-- check for collision with player spaceship
if check_collision(player.x,player.y,asteroid.x,asteroid.y,4,asteroid.collision_size) and not player.isdead and not player.isinvulnerable then
asteroid_collision(asteroid)
player_collision()
else
do_move(asteroid)
end
end
-- if there are no more asteroids, then spawn more
else
if player.ticks_until_next_level < 1 then
player.level += 1
player.level_display_counter = 60
level_asteroids = 1
if player.level < 5 then
level_asteroids += player.level
else
level_asteroids = 5
end
generate_asteroids(level_asteroids)
player.ticks_until_next_level = 90
else
player.ticks_until_next_level -= 1
end
end
do_move(player)
-- we're only tracking button presses when the player is alive
if not player.isdead then
if btn(0) then
player.facing_angle += 5
if player.facing_angle > 360 then
player.facing_angle -= 360
end
end
if btn(1) then
player.facing_angle -= 5
if player.facing_angle < 0 then
player.facing_angle += 360
end
end
if btn(2) then
-- update velocity and v-angle
player.vx = mid(-player.max_thrust,
player.vx + cos(player.facing_angle/360) * -player.thrust,
player.max_thrust)
player.vy = mid(-player.max_thrust,
player.vy + sin(player.facing_angle/360) * -player.thrust,
player.max_thrust)
sfx(1)
else
-- otherwise, we're going to apply the space breaks (physics be damned)
player.vx *= 0.99
player.vy *= 0.99
end
-- if they're trying to shoot and they have less than five bullets on screen,
if btnp(4) and #laser_shots < 5 then
local nose_angle = player.facing_angle - 180
shoot(player.x + 5 * cos(nose_angle/360),
player.y + 5 * sin(nose_angle/360),
cos(player.facing_angle/360) * -2,
sin(player.facing_angle/360) * -2)
sfx(0)
end
if player.invulnerability_counter > 0 then
player.invulnerability_counter -= 1
if player.invulnerability_counter == 1 then
player.isinvulnerable = false
end
end
else
-- if we have no more lives, game over
if player.lives == 0 then
if player.ticks_until_respawn == 0 then
for i=0,5,1 do
if btnp(i) then
new_game()
break
end
end
else
player.ticks_until_respawn -= 1
end
else
if player.ticks_until_respawn == 0 then
player.isdead = false
else
player.ticks_until_respawn -= 1
end
end
end
if player.level_display_counter > 0 then player.level_display_counter -= 1 end
do_tick()
end
function game_draw()
cls()
rectfill(0,0,128,128,1)
for explosion in all(explosions) do
for bit in all(explosion.bits) do
pset(bit.x,bit.y,bit.color)
end
end
for laser_beam in all(laser_shots) do
circfill(laser_beam.x,laser_beam.y,1,7)
end
draw_asteroids()
-- we're only going to draw the spaceship if the player isn't dead
if player.isdead then
if player.lives == 0 then
if blink then
centerprint('game over',64,36,7)
end
centerprint('play again?',64,85)
end
else
if not player.isinvulnerable or player.isinvulnerable and tick%15>7 then
draw_spaceship(player.x, player.y, player.facing_angle)
end
if player.level_display_counter > 0 then
local level_string = 'level '..player.level
print(level_string,64-#level_string*4/2,36,7)
end
end
print_score()
for i=1,player.lives-1,1 do
draw_spaceship(i*5,11,271)
end
--print('2016 vlek',45,122)
end
function draw_spaceship(x_coord, y_coord, ship_angle)
local nose_angle = ship_angle - 180
local nose_x_coord = x_coord + 4 * cos(nose_angle/360)
local nose_y_coord = y_coord + 4 * sin(nose_angle/360)
local line_points = {}
for angle in all({30,-30}) do
local x = x_coord + 4 * cos((ship_angle + angle)/360)
local y = y_coord + 4 * sin((ship_angle + angle)/360)
add(line_points, {x,y})
line(nose_x_coord,nose_y_coord,x,y,7)
end
-- connect the two legs of the spaceship
line(line_points[1][1],line_points[1][2],line_points[2][1],line_points[2][2],7)
end
function draw_asteroids()
for asteroid in all(asteroids) do
for point=1,#asteroid.points-1,1 do
line(asteroid.points[point][1]+asteroid.x,
asteroid.points[point][2]+asteroid.y,
asteroid.points[point+1][1]+asteroid.x,
asteroid.points[point+1][2]+asteroid.y,
7)
end
-- lastly, draw the line between the first and last points:
line(asteroid.points[1][1]+asteroid.x,
asteroid.points[1][2]+asteroid.y,
asteroid.points[#asteroid.points][1]+asteroid.x,
asteroid.points[#asteroid.points][2]+asteroid.y,7)
end
end
function shoot(x, y, vx, vy)
local laser_beam = {}
laser_beam.x = x
laser_beam.y = y
laser_beam.vx = vx
laser_beam.vy = vy
laser_beam.movement_count = 0
add(laser_shots, laser_beam)
end
function do_move(obj)
-- perform movement:
obj.x += obj.vx
obj.y += obj.vy
-- if we're out of screen bounds, throw us back in on the other side:
for axis in all({'x','y'}) do
if obj[axis] < 0 then
obj[axis] += 128
elseif obj[axis] > 128 then
obj[axis] -= 128
end
end
end
function do_tick()
if tick < 29 then
tick += 1
else
tick = 1
if blink then
blink = false
else
blink = true
end
end
end
function randint(min, max)
local range=(max+1)-min
return flr(rnd(range)+min)
end
function randfloat(max)
-- this is really misleading, but it gives a positive or a negative
-- float up to but not including the maximum
if randint(0,1) == 1 then
return rnd(max)
else
return rnd(max) * -1
end
end
function create_asteroid(x,y,vx,vy,size)
local asteroid = {}
asteroid.x = x
asteroid.y = y
asteroid.vx = vx
asteroid.vy = vy
asteroid.size = size
asteroid.points = {}
asteroid.collision_size = 0
local degrees = 360
while degrees > 0 do
if degrees < 30 then
degrees = 0
else
degrees -= randint(20,50)
end
local new_x = randint(size/2,size)
local dx = new_x * cos(degrees/360)
local new_y = randint(size/2,size)
local dy = new_y * sin(degrees/360)
for v in all({new_x, new_y}) do
if v > asteroid.collision_size then
asteroid.collision_size = v
end
end
add(asteroid.points, {dx,dy})
end
add(asteroids, asteroid)
end
function create_explosion(x,y)
local explosion = {}
explosion.x = x
explosion.y = y
explosion.bits = {}
for i=0,randint(5,9),1 do
local bit = {}
bit.x = x
bit.y = y
bit.vx = randfloat(2)
bit.vy = randfloat(2)
bit.color = explosion_colors[randint(1,#explosion_colors)]
add(explosion.bits, bit)
end
explosion.steps = 0
add(explosions, explosion)
end
function check_collision(x1,y1,x2,y2,rad1,rad2)
-- check collision for two objects based on central points and radii
return rad1+rad2 >= sqrt((x2-x1)^2+(y2-y1)^2)
end
function generate_asteroids(asteroid_count)
for pico=1,asteroid_count,1 do
local x = randint(1,127)
local y = randint(1,127)
if randint(0,1) == 1 then
y = randint(0,1) * 127
else
x = randint(0,1) * 127
end
create_asteroid(x,y,randfloat(1),randfloat(1),8)
end
end
function asteroid_collision(asteroid)
-- if it wasn't a small asteroid, spawn more
if asteroid.size == 8 or asteroid.size == 4 then
for i=1,2,1 do
create_asteroid(asteroid.x,asteroid.y,randfloat(2),randfloat(2),asteroid.size/2)
end
end
if asteroid.size == 8 then
increase_score(10)
elseif asteroid.size == 4 then
increase_score(25)
else
increase_score(50)
end
create_explosion(asteroid.x,asteroid.y)
del(asteroids, asteroid)
sfx(2)
end
function player_collision()
player.lives -= 1
player.isdead = true
player.ticks_until_respawn = 30
player.x = 64
player.y = 64
player.vx = 0
player.vy = 0
player.isinvulnerable = true
player.invulnerability_counter = 60
if player.lives == 0 then sfx(4) end
end
function centerprint(text,x,y,color)
if color == nil then color = 7 end
print(text,x-#text*4/2,y,color)
end
function increase_score(points)
local old_score = player.score1
player.score1 += points
if flr((player.score1 / 5000)) > flr((old_score / 5000)) then
player.lives += 1
sfx(3)
end
if player.score1 >= 10000 then
player.score2 += 1
player.score1 -= 10000
end
end
function print_score()
-- cleverly printing the player's score
color(7)
local x_offset = 0
if player.score2 > 0 then
print(player.score2)
x_offset += 20 - #(player.score1.."") * 4
end
for i=4,x_offset-4,4 do
print('0',i,0)
end
print(player.score1,x_offset,0)
end
Conclusion
I was able to make a fully-functional version of Asteroids in a relatively small amount of code in a toy game engine. The game itself is a little aggravating to play due to the small resolution that the engine offers, but is good for some edge-of-your-seat gameplay as asteroids quickly fly passed.
As a learning project, I feel like this was a great opportunity. There were several things, especially fundamental game making concepts, that I was able to touch such as holding state for the intro, gameplay, and game over screens. There are a number of things that I would do differently today with the knowledge that I gained from the project, but I believe that that is a very common thing in programming which only shows growth.
Overall assessment
I am proud of the finished product and also am happy that I took the time to learn what I did. I tried my best to first try things on my own before researching how others do things as a way to test myself. This retrofitting has caused some disjointed code and the single-file nature of the game files does not help this problem either.
Things that could be better
Gameplay
The game, due to the low resolution, makes it very difficult to play for long without dying. The controls themselves are fine, but, with how little game play area there is, the asteroids are upon the user quicker than in the original game. If I was going to tweak this, I would probably reduce the number of asteroids that appear on screen, make them a bit smaller, or change the asteroid sizes when they do appear.
Difficulty options
Right now there is no setting for how hard the game ought to be. Even something like an Easy, Medium, and Hard mode selection would help with it dictating things like the number of lives that the user starts with and how big and how many asteroids are generated per level.
Suggestible?
As a learning project, Asteroids is one of the better ones that I did. I would suggest others give it a shot and try their best to come up with efficient ways to add all of the functionality themselves.