The last game we made in a day was minesweeper, and we were very successful. This time we've got another simple game, space invaders. I would argue that space invaders is simpler than minesweeper. Well, we're going to find out either way.
If you don't know the challenge already, it's to make it in one day. Before we start coding, and start that timer, we fully design a spec. This way we don't feel cheated for forgetting something at the end. If our application adheres to our concrete design list, we've made space invaders!
I'll be using Golang and Ebitengine. I'll also be using my own UI code. All other code will be written within on day, and hopefully you can do the same. Choose your own language, game engine, whatever, the idea of the code should be the same.
From the User's Perspective
There are only 2 phases in minesweeper, game, and lose. Ultimately, you keep playing until you lose, showing your score. There is a player at the bottom of the screen, who shoots projectiles upward. The player only moves left and right, same as the aliens. The aliens, however, move down a row when they reach a side. Every time you kill an alien, you get an amount of points. You have 3 lives. The enemies can also shoot back, which can kill the player or damage the towers. The towers degrade as they get hit by any projectile. We're going to have 3 different types of aliens. If all towers are destroyed, or all lives are lost, or the aliens reach the bottom, the player loses. Once all aliens are cleared, the board is reset, but the player keeps their points. The aliens slowly speed up over time.
That looks like quite a bit less than we had for minesweeper!
Now let's make that shortlist.
Shortlist
- There are 2 phases, play, lose
- Always show the score
- The player at the bottom can move left and right
- The player shoots projectiles upward
- The alien pack moves left, until they hit a wall, in which they switch direction, and move down a layer
- There are 3 alien types, giving differing amounts of points for kills
- The player has 3 lives
- Aliens occasionally shoot projectiles
- The player loses a life when hit by a projectile
- There are 3 towers
- The towers lose health when hit by a projectile
- If all towers are destroyed, or all lives are lost, or the aliens reach the bottom, push to lose state
- Once all aliens are cleared the board is reset
- The aliens slowly speed up over time.
The Code
Now I've set up a blank window, so let's start the actual code. The 2 phases is pretty simple, we can just use an enum (or, the Golang equivalent).
type Phase byte
const (
PLAY Phase = iota
LOSE
)
We need to have the score on the screen at all times. We're going to have an integer that shows on screen constantly. Then we update it as needed.
func (g *Game) updateScore() {
g.play.Text[0].Text = strconv.Itoa(g.score)
g.lose.Text[0].Text = strconv.Itoa(g.score)
}
So now we need a player that can sit at the bottom of the screen shooting upwards.
type Player struct {
x, y float64
weaponCooldown int
weaponCooldownMax int
img *ebiten.Image
}
Here's the data for the player, and look at it one screen!.
func (p *Player) update(g *Game) {
if p.weaponCooldown > 0 {
p.weaponCooldown--
} else {
if ebiten.IsKeyPressed(ebiten.KeySpace) {
p.shoot(g)
}
}
if ebiten.IsKeyPressed(ebiten.KeyA) {
p.x--
}
if ebiten.IsKeyPressed(ebiten.KeyD) {
p.x++
}
if p.x < 0 {
p.x = 0
}
if p.x > 100-ENTITY_SIZE {
p.x = 100 - ENTITY_SIZE
}
}
Last thing for the player is shooting, but of course, we need to have something to shoot. So we're going to make some bullets.
type Bullet struct {
x, y float64
dy float64
}
We can use this struct for enemies later as well, by just switching dy so that the bullet travels down instead.
func (p *Player) shoot(g *Game) {
p.weaponCooldown = p.weaponCooldownMax
g.bullets = append(g.bullets, Bullet{
x: p.x + ENTITY_SIZE/2 - BULLET_WIDTH/2,
y: p.y - BULLET_HEIGHT - 1,
dy: -1,
})
}
This method is then called when we have the space bar held down. Look, the new bullet's x
and y
look a bit strange, all that is to get it lined up right. I've also done a little trick here. The bullet's update method returns a boolean, which turns true when it steps out of bounds.
for i := len(g.bullets) - 1; i >= 0; i-- {
if g.bullets[i].update(g) {
g.bullets = slices.Delete(g.bullets, i, i+1)
}
}
While updating the bullets, if we find a dead bullet, we remove it. You may also notice that I move through the list backwards. This is because removing from a list while interating over it leads to issues. You would go to the next element, however, since you remove the last element, the element you want to go to is in the previous position. Going through backwards fixes this issue.
Let's get the player's lives showing up in the top right. Simply adding the number of lives we have to the player, and drawing that many player images in the top left works.
Now we need to start work on a pack of aliens.
type Alien struct {
x, y float64
img *ebiten.Image
shootChance float64
points int
dead bool
}
type AlienPack struct {
pack [PACK_WIDTH][PACK_HEIGHT]Alien
dx float64
}
r := 0
for c := range PACK_WIDTH {
ap.pack[c][r] = Alien{
x: float64((c + 1) * (ENTITY_SIZE + 1)),
y: float64((r + 1) * (ENTITY_SIZE + 1)),
img: g.vis.GetImage("alien3"),
shootChance: 0.0015,
points: 300,
}
}
Aliens here are extremely similar to the player. They have the points they give when killed, and also a small chance to shoot back. We keep them in a pack so it's easier to move them around. Then at the end, we can create a pack by adding a bunch of aliens. And we get a cool result!
Right now this pack doesn't move, but that shouldn't be too hard to fix.
func (ap *AlienPack) update(g *Game) {
flip := false
// Move all the aliens
for c := range len(ap.pack) {
for r := range len(ap.pack[c]) {
ap.pack[c][r].x += ap.dx
if ap.pack[c][r].update(g) {
flip = true
}
}
}
if flip {
ap.flip()
}
}
func (ap *AlienPack) flip() {
// Opposite direction
ap.dx *= -1
// Move down a bit
for c := range len(ap.pack) {
for r := range len(ap.pack[c]) {
ap.pack[c][r].y += ENTITY_SIZE / 2
}
}
}
This code here is pretty simple. Move all enemies in the same direction. Once the pack has hit a wall, flip the movement direction, and move everything down. And getting these enemies to shoot is even easier.
func (a *Alien) update(g *Game) bool {
if a.dead {
return false
}
a.shoot(g)
// Possibly flip direction
if a.x <= 1 {
return true
}
if a.x >= 99-ENTITY_SIZE {
return true
}
return false
}
func (a *Alien) shoot(g *Game) {
if rand.Float64() >= a.shootChance {
return
}
g.bullets = append(g.bullets, Bullet{
x: a.x + ENTITY_SIZE/2 - BULLET_WIDTH/2,
y: a.y + ENTITY_SIZE + 1,
dy: 0.5,
})
}
A key point here is that we know the source of a bullet based on its dy
. If you wanted to have a more in-depth game, maybe with multiplayer, you would have a concrete way to decide the source of a bullet. For our problem, however, this is fine. The reason I'm bringing this up, is because an alien at the back would otherwise hit an alien at the front. For hitting the player, this doesn't matter, so we're going to do that first. The alien's update method returns true if it figured out it should initiate a flip. The alien pack uses this to execute the flip.
func (b *Bullet) hitTarget(g *Game) bool {
// Player collision
if g.player.hit(b) {
return true
}
// Enemy collision
for c := range len(g.pack.pack) {
for r := range len(g.pack.pack[c]) {
a := &g.pack.pack[c][r]
if a.hit(b, g) {
return true
}
}
}
return false
}
func (a *Alien) hit(b *Bullet, g *Game) bool {
if !rectCollide(b.x, b.y, BULLET_WIDTH, BULLET_HEIGHT, a.x, a.y, ENTITY_SIZE, ENTITY_SIZE) {
return false
}
if a.dead {
return false
}
if b.dy != -1 {
return false
}
a.dead = true
g.score += a.points
return true
}
func (p *Player) hit(b *Bullet) bool {
if !rectCollide(b.x, b.y, BULLET_WIDTH, BULLET_HEIGHT, p.x, p.y, ENTITY_SIZE, ENTITY_SIZE) {
return false
}
p.lives--
return true
}
The bullet tries to hit whatever it can. If it finds anything, it only hits that and dies. For the player, it checks collision and loses a life. For the aliens, it has to check a bit more, but if it hits, it just kills it. Dead aliens don't visibly show, can't initiate a flip, and can't shoot.
Let's check how far we've gotten.
- [x] There are 2 phases, play, lose
- [x] Always show the score
- [x] The player at the bottom can move left and right
- [x] The player shoots projectiles upward
- [x] The alien pack moves left, until they hit a wall, in which they switch direction, and move down a layer
- [x] There are 3 alien types, giving differing amounts of points for kills
- [x] The player has 3 lives
- [x] Aliens occasionaly shoot projectiles
- [x] The player loses a life when hit by a projectile
- [ ] There are 3 towers
- [ ] The towers lose health when hit by a projectile
- [ ] If all towers are destroyed, or all lives are lost, or the aliens reach the bottom, push to lose state
- [ ] Once all aliens are cleared the board is reset
- [ ] The aliens slowly speed up over time.
Looks like we've done quite a bit so far, but we've got a bit to go for a playable game. Let's leave towers until last, and focus on the last alien things.
ap.dx *= 1.01
A single line at the start of an alien pack's flip method, or when we clear the pack. This adds to the diffulty overtime, and eventually makes it too hard to beat the pack.
func (g *Game) checkWinConditions() {
if g.pack.empty() {
g.pack.newPack(g)
}
}
func (ap *AlienPack) empty() bool {
for r := range PACK_HEIGHT {
for c := range PACK_WIDTH {
if !ap.pack[c][r].dead {
return false
}
}
}
return true
}
Here, the code for starting a new pack once we've killed the last is very simple. So now we have to start working on those towers.
type Tower struct {
x, y float64
health int
img *ebiten.Image
}
func (t *Tower) hit(b *Bullet, g *Game) bool {
if t.health == 0 {
return false
}
if !rectCollide(b.x, b.y, BULLET_WIDTH, BULLET_HEIGHT, t.x, t.y, ENTITY_SIZE, ENTITY_SIZE) {
return false
}
t.health -= 1
return true
}
func (t *Tower) draw(g *Game) {
if t.health > 0 {
g.vis.DrawImage(t.img, t.x, t.y, ENTITY_SIZE, ENTITY_SIZE, &ebiten.DrawImageOptions{})
}
}
Very simple looking so far, let's place them whenever we start a game.
for i := range 3 {
g.towers[i] = Tower{
x: float64(25 * (i + 1)),
y: 50 - 2*(ENTITY_SIZE+1),
health: 10,
img: g.vis.GetImage("tower"),
}
}
So now we've got one more thing to do to, and this game is finished!
We have to get the lose conditions correct, those being.
- All towers destroyed
- All lives lost
- Aliens have reached the towers
func (g *Game) checkLoseConditions() {
// Player lives check
if g.player.lives == 0 {
g.phase = LOSE
return
}
// Tower lives check
allTowersDead := true
for i := range g.towers {
if g.towers[i].health > 0 {
allTowersDead = false
break
}
}
if allTowersDead {
g.phase = LOSE
return
}
// Enemy breached check
towerY := g.towers[0].y
for r := range PACK_HEIGHT {
for c := range PACK_WIDTH {
e := &g.pack.pack[c][r]
if e.dead {
continue
}
if e.y+ENTITY_SIZE >= towerY {
g.phase = LOSE
return
}
}
}
}
We don't care whether a alien directly collides with a tower, just as long as they're low enough to do so. So the last thing is that lose page, that we can reset from, and to show our final score.
Then this takes us straight back into a new game. And we're done!
Done
As usual the code will be in a GitHub repo along with the minesweeper game we made last time. Hopefully you followed along, or went your own way making your own. At the very least, you now have your own space invaders that you can fiddle and tinker with.
Your Next Steps
Now that you have space invaders, here are some ways you could possibly extend it.
- Add other variations to the enemy types, such as how fast they shoot, and how much damage they do
- Maybe the aliens don't move together as a pack
- Of course, the classic leaderboard
- Add multiplayer (whether on the same machine, or over a connection)
- Add upgrades that can be bought using points
- Add different types of players, such as one that's faster, or one with a rocket launcher that does splash damage
- Allow the player to charge up their shot to make it more powerful
If you have any more ideas for games I should explain and make, put them in the comments.
Top comments (1)
Great idea for a series. Excited for the next entry!