Build an Asteroids Game with Raylib-go

In this tutorial, you’ll learn how to build an Asteroids game using Raylib-go, a lightweight library for game development.
By the end, we will have a complete game with player movement controlled by the keyboard, shooting, collisions, and win/lose states and all in Go.
Demo
Setting up your Go project
First, we need to set up our project and pull Raylib-go.
mkdir go-asteroids
go mod init asteroids
go get -v -u github.com/gen2brain/raylib-go/raylib
Setting up the Game
We start by creating basic game loop functions to initialise the resources, update the state, draw it to the screen and deinitialise the resources.
const (
screenWidth = 800
screenHeight = 400
)
func init() {
//Builtin go function which runs before main()
// Setup the raylib window
rl.InitWindow(screenWidth, screenHeight, "Asteroids")
rl.SetTargetFPS(60)
}
func draw() {
rl.BeginDrawing()
// Set the background to black
rl.ClearBackground(rl.Black)
// Draw the score to the screen
rl.DrawText("Score 0", 10, 10, 20, rl.Gray)
rl.EndDrawing()
}
func update() {
//TODO: Update the state
}
func deinit() {
rl.CloseWindow()
}
func main() {
// When the main function ends, call the deinit() function
defer deinit()
// Continue the loop until the window is closed or ESC is pressed
for !rl.WindowShouldClose() {
draw()
update()
}
}
First, we set up a main()
function, which starts by deferring the deinit()
function. This will ensure that the deinit()
function runs at the end of the main() function.We use an init() function, which runs before the main() function. These two functions allow us to set up resources in memory and tear them down when we no longer need them.
In the init()
function, we set up the Raylib window and set the FPS.
We create a draw()
function to use the new RayLib window to draw to the screen. We start the drawing with BeginDrawing
and finish it with EndDrawing
. We define our draw methods between those two calls. We set up an update()
function, which is called each frame. We will fill that out later.
We simply clear the background with black and then draw a score in the top left corner.
In the main()
function, we are called WindowShouldClose
, which is a function that allows us to run our game loop until the Raylib window is closed or the ESC key is pressed.
Now, we can run the code:
go run main.go
What we should see is a black window with the score in the top left hand corner.
The code for this section can be found on my GitHub Repo
Drawing a background
It is a great start, though, we want to make this start look like our game. To start, we can add the space background.
We create a new resources/
directory in our project and download the space_background.png into it.
We load the image from a file and display it as our background:
var (
texBackground rl.Texture2D
)
We start by declaring some global state, with the background text. Next, we need to load the image:
func init() {
//Builtin go function which runs before main()
// Setup the raylib window
rl.InitWindow(screenWidth, screenHeight, "Asteroids")
rl.SetTargetFPS(60)
// Load textures
texBackground = rl.LoadTexture("resources/space_background.png")
}
We load the image from disk using the LoadTextures
call and assign it to the texBackground
variable.
We need to make sure we are unloading it after the game, so we can do that in the deinit()
function.
func deinit() {
rl.CloseWindow()
// Unload textures when the game closes
rl.UnloadTexture(texBackground)
}
To do that, we run the UnloadTexture
call.
Now, we can draw the background to the screen:
func draw() {
rl.BeginDrawing()
// Set the background to a nebula
bgSource := rl.Rectangle{X: 0, Y: 0, Width: float32(texBackground.Width), Height: float32(texBackground.Height)}
bgDest := rl.Rectangle{X: 0, Y: 0, Width: screenWidth, Height: screenHeight}
rl.DrawTexturePro(texBackground, bgSource, bgDest, rl.Vector2{X: 0, Y: 0}, 0, rl.White)
// Draw the score to the screen
...
We have a bit of an issue, the image is 1536x1024px, when our window size is only 800x400px. To scale the image to our window, we need to use the DrawTexturePro
call.
This call takes the texture we want to draw, a rectangle within that texture and a destination texture on our window to draw it to. It also takes an Origin point, a Rotation and a Color, however, we are not using them.
For the bgSource
, which is used to select the part of the image to draw, we create a rectangle to select the whole image. For the bgDest
, we select the whole screen in Raylib. We then call the DrawTexturePro
to draw the image to the screen.
Let’s check to see that our background is being shown:
go run main.go
Which results in:
Beautiful, now it looks ready to start building our game on top of.
The code for this section can be found on my GitHub Repo
Create and draw the player
Next, we can set up the player for the game. This will be a ship that flies around and shoots at the asteroids.
For this, I am going to use Kenney - Simple Space assets. There are some amazingly talented game artists around, and many of them provide assets for free.
We can download the assets and put them into our /resources
directory.
Now, we load them into our game. We start by adding another texture to our game state:
var (
+ texTiles rl.Texture2D
texBackground rl.Texture2D
)
Then we need to load it in our init()
function:
// Load textures
+ texTiles = rl.LoadTexture("resources/tilesheet.png")
texBackground = rl.LoadTexture("resources/space_background.png")
Then unload it in our deinit()
function:
// Unload textures when the game closes
rl.UnloadTexture(texBackground)
+ rl.UnloadTexture(texTiles)
}
Now that we have the texture loaded into memory, we can start to use it. However, as you may have spotted, the texture is a spritemap, made up of an 8x6 grid of sprites. We want to use one of them for our ship.
Luckily, we already saw a function that would allow us to select a section of the texture and draw it to the screen. We can use the DrawTexturePro
to take a smaller section of the original image and then draw that for us.
Let’s start by defining our source selection rectangle. We will also create a tileSize
to track how large we want the image.
In the consts
, we add the tileSize
:
const (
screenWidth = 800
screenHeight = 400
+ tileSize = 64
)
Now, we define our source selection rectangle for the ship:
var (
texTiles rl.Texture2D
texBackground rl.Texture2D
+ spriteRec rl.Rectangle
)
Then, in the init()
function, we can define the sprite source:
texTiles = rl.LoadTexture("resources/tilesheet.png")
texBackground = rl.LoadTexture("resources/space_background.png")
+ spriteRec = rl.Rectangle{X: tileSize * 0, Y: tileSize * 2, Width: tileSize, Height: tileSize}
}
We select the ship on the 3rd row (Index 2) on the 1st position (Index 0).
As the tiles on the map are tileSize
wide and high. We use that variable to calculate where in the tilemap we want to select. You can see from the image below how we have picked the ship we want.
Now we have our selection, we need to draw it on the screen. To make these easier, we create a Player
struct to hold all the player related behaviours.
type Player struct {
position rl.Vector2
speed rl.Vector2
size rl.Vector2
acceleration float32
rotation float32
isBoosting bool // We will use this in the next step
}
We create the Player
structure with Vectors for the position
, speed
and size
. We have some floats for acceleration
and rotation
, which we use to move the sprite. Finally, we have a boolean for boosting.
Now we can draw the sprite to the screen:
func (p *Player) Draw() {
destTexture := rl.Rectangle{X: p.position.X, Y: p.position.Y, Width: p.size.X, Height: p.size.Y}
rl.DrawTexturePro(
texTiles,
spriteRec,
destTexture,
rl.Vector2{X: p.size.X / 2, Y: p.size.Y / 2},
p.rotation,
rl.White,
)
}
We define a destTexture
, similar to the way we did the background; however, instead of covering the whole screen, we want the sprite to appear in the centre of the screen.
We need to create the new player when we start the game. For this, we create a variable in the state and a little helper function to do all the initialisation:
var (
texTiles rl.Texture2D
texBackground rl.Texture2D
spriteRec rl.Rectangle
+ player Player
)
New initGame()
function:
func initGame() {
player = Player{
position: rl.Vector2{X: 400, Y: 200},
speed: rl.Vector2{X: 0.0, Y: 0.0},
size: rl.Vector2{X: tileSize, Y: tileSize},
rotation: 0.0,
acceleration: 0.0,
isBoosting: false,
}
}
We need to add the new initialisation of the game to the init()
function:
...
texBackground = rl.LoadTexture("resources/space_background.png")
spriteRec = rl.Rectangle{X: tileSize * 0, Y: tileSize * 2, Width: tileSize, Height: tileSize}
+ initGame()
}
Now we have a player
object, we need to add it to the draw()
in the main game loop:
...
rl.DrawTexturePro(texBackground, bgSource, bgDest, rl.Vector2{X: 0, Y: 0}, 0, rl.White)
+ //Draw the player
+ player.Draw()
// Draw the score to the screen
rl.DrawText("Score 0", 10, 10, 20, rl.Gray)
...
Looks like that is all we need to draw the player, let’s give it a shot and see if it works:
go run .
We should see our background image, and our new sprite drawn into the window in the middle of the screen:
Great!
We are successfully using the tilemap and drawing a sprite to the screen.
The code for this section can be found on my GitHub Repo
Implementing player movement
Next, we start to give the game some life, with some movement.
We want to move the player using the keyboard. So we will need to set up some code to manage the keyboard inputs and manipulate our sprite accordingly.
Let’s start by creating some constants for the speed of the player and the rotation speed. This allows us to tweak it later.
const (
screenWidth = 800
screenHeight = 400
tileSize = 64
+ rotationSpeed = 2.0
+ playerSpeed = 6.0
)
Now we have our variables, we can create an Update()
method on our Player struct to move the player when the keyboard is used:
func (p *Player) Update() {
// Rotate the player with the arrow keys
if rl.IsKeyDown(rl.KeyLeft) {
player.rotation -= rotationSpeed
}
if rl.IsKeyDown(rl.KeyRight) {
player.rotation += rotationSpeed
}
// Accelerate the player with up
if rl.IsKeyDown(rl.KeyUp) {
if player.acceleration < 0.9 {
player.acceleration += 0.1
}
}
// Decellerate the player with down
if rl.IsKeyDown(rl.KeyDown) {
if player.acceleration > 0 {
player.acceleration -= 0.05
}
if player.acceleration < 0 {
player.acceleration = 0
}
}
// Get the direction the sprite is pointing
direction := getDirectionVector(player.rotation)
// Start to move to the direction
player.speed = rl.Vector2Scale(direction, playerSpeed)
// Accelerate in that direction
player.position.X += player.speed.X * player.acceleration
player.position.Y -= player.speed.Y * player.acceleration
// To void losing our ship, we wrap around the screen
wrapPosition(&p.position, tileSize)
}
We start by capturing the KeyLeft
and KeyRight
events, then rotate the ship based on the direction. We capture KeyUp
and KeyDown
for acceleration.
To make it a little more realistic, the ship has no friction (Just like space!), so we need to actively decelerate to slow down.
We use the getDirectionVector
to translate our rotation into a vector, and we use that vector to change the direction of the ship with the speed
attribute. We then add the acceleration
and the speed
to the position to move the ship in that direction.
Finally, we use the wrapPosition
function to wrap the position so our ship is not lost in space as soon as we leave the screen.
We need to create our getDirectionVector()
function:
func getDirectionVector(rotation float32) rl.Vector2 {
// Convert the rotation to radians
radians := float64(rotation) * rl.Deg2rad
// Return the vector of the direction we are pointing at
return rl.Vector2{
X: float32(math.Sin(radians)),
Y: float32(math.Cos(radians)),
}
}
And our wrapPostion()
function:
func wrapPosition(pos *rl.Vector2, objectSize float32) {
// If we go off the left side of the screen
if pos.X > screenWidth+objectSize {
pos.X = -objectSize
}
// If we go off the right side of the screen
if pos.X < -objectSize {
pos.X = screenWidth + objectSize
}
// If we go off the bottom of the screen
if pos.Y > screenHeight+objectSize {
pos.Y = -objectSize
}
// If we go off the top of the screen
if pos.Y < -objectSize {
pos.Y = screenHeight + objectSize
}
}
Finally, we can replace our Update()
method with the main game loop to move the ship each frame:
func update() {
player.Update()
}
Let’s run go run main.go
to see if we can move the ship.
Amazing!
Our player is moving based on the keypresses from the arrow keys.
The code for this section can be found on my GitHub Repo
Adding a booster
To give the game a little moe realism, let’s add a boost to the little ship, so it looks like that is how the ship is moving.
We already have the isBoosting
attribute on the Player struct. Let’s use that.
We will select another sprite from the tilemap and draw it only when we are boosting.
var (
texTiles rl.Texture2D
texBackground rl.Texture2D
spriteRec rl.Rectangle
+ boostRec rl.Rectangle
player Player
)
We create a new selection rectangle from the tilemap in the global game state.
texBackground = rl.LoadTexture("resources/space_background.png")
+ // Sprites for the ship and it boost
spriteRec = rl.Rectangle{X: tileSize * 0, Y: tileSize * 2, Width: tileSize, Height: tileSize}
+ boostRec = rl.Rectangle{X: tileSize * 7, Y: tileSize * 5, Width: tileSize, Height: tileSize}
initGame()
We then select the tile we want to use. We want to use the yellow thrust pattern, which makes it look like the ship is activating its engine.
func (p *Player) Draw() {
destTexture := rl.Rectangle{X: p.position.X, Y: p.position.Y, Width: p.size.X, Height: p.size.Y}
+ if p.isBoosting {
+ rl.DrawTexturePro(
+ texTiles,
+ boostRec,
+ destTexture,
+ rl.Vector2{X: p.size.X / 2, Y: p.size.Y/2 - 40},
+ p.rotation,
+ rl.White,
+ )
+ }
rl.DrawTexturePro(
We update the Draw()
method on the Player struct to check if we are boosting; if we are, draw the boost sprite under the ship sprite.
if rl.IsKeyDown(rl.KeyRight) {
player.rotation += rotationSpeed
}
+ // Default to not boosting
+ player.isBoosting = false
// Accelerate the player with up
if rl.IsKeyDown(rl.KeyUp) {
if player.acceleration < 0.9 {
player.acceleration += 0.1
}
+ player.isBoosting = true
}
Finally, we add a toggle on the acceleration action so isBoosting
is true when accelerating and defaults to false when we are not.
Let’s run the game with go run main.go
It now looks like our ship is using its engines to speed up!
The code for this section can be found on my GitHub Repo
Defining our Asteroids
Now that we have the player boosting and moving, we need to add the asteroids to the scene, so the player has something to dodge and shoot.
We start by following the same sprite selection pattern as before. We create a rectangle to select and we initialise it.
We also need a place to store the asteroids:
var (
texTiles rl.Texture2D
texBackground rl.Texture2D
spriteRec rl.Rectangle
boostRec rl.Rectangle
+ asteroidRec rl.Rectangle
+ asteroids []Asteroid
player Player
)
We create an asteroidRec
to select the sprite and an asteroids
slice to store them in.
const (
screenWidth = 800
screenHeight = 400
tileSize = 64
rotationSpeed = 2.0
playerSpeed = 6.0
+ initialAsteroids = 5
)
We also add a constant for how many asteroids we want to spawn in to start with.
In the classic Asteroids manner, we are going to start with large asteroids. When we shoot them, they will break apart and spawn smaller asteroids.
To do this, we need a way to track the size of the asteroid. We can use an enum for this, though. Go does not have an enum construct; we can use the iota
to create one:
// Enum for storing the size of the asteroid
type AsteroidSize int
const (
Large AsteroidSize = iota
Medium
Small
)
Here, we create a new type AsteroidSize
and provide it with three named options Large
, Medium
and Small
. Under the hood, these are ints. However, we can now use them to make our code easier to read.
Now to create the Asteroid:
type Asteroid struct {
position rl.Vector2
speed rl.Vector2
size rl.Vector2
asteroidSize AsteroidSize
}
func (a *Asteroid) Draw() {
// Draw the asteroid to the screen
destTexture := rl.Rectangle{X: a.position.X, Y: a.position.Y, Width: a.size.X, Height: a.size.Y}
rl.DrawTexturePro(
texTiles,
asteroidRec,
destTexture,
rl.Vector2{X: a.size.X / 2, Y: a.size.Y / 2},
0.0,
rl.White,
)
}
func (a *Asteroid) Update() {
// Move the asteroid in its direction
a.position = rl.Vector2Add(a.position, a.speed)
// Wrap the position, so they are always on screen
wrapPosition(&a.position, a.size.X)
}
We start with the Asteroid
, which contains the position
, size
and speed
vectors. We have the new asteroidSize
to determine how big to draw the sprite.
We create the Draw()
method to draw the sprite onto the screen.
In the Update()
method, we add the speed
to the position
to move the asteroid and ensure that our asteroids wrap around the screen.
Creating Asteroids
Now that we have our Asteroid defined, we can start to create some when the game initialises. To do this, we create a helper function to generate a Large Asteroid in a random place, with a random speed and direction.
// Asteroid helper functions
func createLargeAsteroid() Asteroid {
// Generate a random edge of the screen to spawn
randomEdge := rl.GetRandomValue(0, 3)
var position rl.Vector2
// Generate a random position on screen
randomX := float32(rl.GetRandomValue(0, screenWidth))
randomY := float32(rl.GetRandomValue(0, screenHeight))
switch randomEdge {
case 0:
position = rl.Vector2{X: randomX, Y: +tileSize}
case 1:
position = rl.Vector2{X: screenWidth + tileSize, Y: randomY}
case 2:
position = rl.Vector2{X: randomX, Y: screenHeight + tileSize}
case 3:
position = rl.Vector2{X: -tileSize, Y: randomY}
}
// Generate a random speed and direction for the asteroid
speed := rl.Vector2{
X: float32(rl.GetRandomValue(-10, 10)) / 10,
Y: float32(rl.GetRandomValue(-10, 10)) / 10,
}
// Create the large asteroid
return createAsteroid(Large, position, speed)
}
This function uses the getRandomValue
function to generate a number between 0
and 3
, which represent different sides of the screen. Then it creates a vector with a random speed. Finally, it passes it to another helper function to create the Asteroid.
func createAsteroid(asteroidSize AsteroidSize, position, speed rl.Vector2) Asteroid {
// Scale the image of the asteroid based on the asteroidSize
var size rl.Vector2
switch asteroidSize {
case Large:
size = rl.Vector2{X: tileSize * 1.0, Y: tileSize * 1.0}
case Medium:
size = rl.Vector2{X: tileSize * 0.7, Y: tileSize * 0.7}
case Small:
size = rl.Vector2{X: tileSize * 0.4, Y: tileSize * 0.4}
}
// Create the asteroid
return Asteroid{
position: position,
speed: speed,
size: size,
asteroidSize: asteroidSize,
}
}
This function takes in a size and its vectors and creates an Asteroid of that size at the vectors. The helper function can be reused later when we want to create more asteroids.
Finally, we need to update the initGame()
function to create our initial set of asteroids.
func initGame() {
+ // Create the asteroids field
+ asteroids = nil
+ for range initialAsteroids {
+ asteroids = append(asteroids, createLargeAsteroid())
+ }
// Create the player
player = Player{
We will also need to define our selection rectangle in the init()
function, for the asset in the tile map:
// Sprites for the ship and it boost
spriteRec = rl.Rectangle{X: tileSize * 0, Y: tileSize * 2, Width: tileSize, Height: tileSize}
boostRec = rl.Rectangle{X: tileSize * 7, Y: tileSize * 5, Width: tileSize, Height: tileSize}
+ // Sprite for the asteroid
+ asteroidRec = rl.Rectangle{X: tileSize * 1, Y: tileSize * 4, Width: tileSize, Height: tileSize}
+
initGame()
}
Now, let’s run the game with go run main.go
and see what we have:
We now have moving Asteroids!
The game is really coming to life now.
The code for this section can be found on my GitHub Repo
Enable collision detection
Next, let’s add some interaction to the elements in the game.
We will start with collision detection between the player ship and the asteroids.
We will start with the consequence. The game should end when an asteroid hits the player.
We start by creating a Game Over mechanic by adding a global state for it:
var (
texTiles rl.Texture2D
texBackground rl.Texture2D
spriteRec rl.Rectangle
boostRec rl.Rectangle
asteroidRec rl.Rectangle
asteroids []Asteroid
player Player
+ gameOver bool
)
We initialise that to be false in the initGame()
function:
func initGame() {
+
+ // Start with it not being game over
+ gameOver = false
+
// Create the asteroids field
asteroids = nil
When the game is over, we want our game to stop. We can do this by only running the update when it is NOT gameover:
func update() {
// If it is not game over, update the frame
if !gameOver {
// Update the player
player.Update()
// Update the asteroid field
for i := range asteroids {
asteroids[i].Update()
}
}
}
Next, we draw the Game Over to the screen.
// Draw the asteroid field
for i := range asteroids {
asteroids[i].Draw()
}
+ if gameOver {
+ drawCenteredText("Game over", screenHeight/2, 50, rl.Red)
+ }
// Draw the score to the screen
rl.DrawText("Score 0", 10, 10, 20, rl.Gray)
We use a little helper function called drawCenteredText
to measure how big our text is and then centre the text.
func drawCenteredText(text string, y, fontSize int32, color rl.Color) {
textWidth := rl.MeasureText(text, fontSize)
rl.DrawText(text, screenWidth/2-textWidth/2, y, fontSize, color)
}
Great, now we can terminate the game when an Asteroid hits the player.
Let’s check for collisions in our update()
function:
for i := range asteroids {
asteroids[i].Update()
}
+ checkCollisions()
}
Now we can define our function, which detects when the player has collided with an Asteroid:
func checkCollisions() {
for i := len(asteroids) - 1; i >= 0; i-- {
// Check for collision between player and asteroid
if rl.CheckCollisionCircles(
player.position,
player.size.X/4,
asteroids[i].position,
asteroids[i].size.X/4,
) {
gameOver = true
}
}
}
We loop through each asteroid, starting from the end of the slice to the front. For each asteroid, we run the CheckCollisionCircles
function. This takes a position and a radius. We use the player’s position
and size
. We do the same with the asteroid, including scaling the radius to ensure the boundary is around the object.
When there is a collision, we set gameOver
to true.
Ok, let’s see if this works:
Great, we are now detecting the collision between the player and the asteroids.
The code for this section can be found on my GitHub Repo
Making our ship shoot
Now that we have our asteroids able to end our game, it is time for us to equip our ship with the ability to fight back.
We will provide our ship with lasers to blast the asteroids.
First, let’s create a struct for our laser:
type Shot struct {
position rl.Vector2
speed rl.Vector2
radius float32
active bool
}
func (s *Shot) Draw() {
if s.active {
rl.DrawCircleV(s.position, s.radius, rl.Yellow)
}
}
func (s *Shot) Update() {
if s.active {
s.position.X += s.speed.X
s.position.Y -= s.speed.Y
if s.position.X < 0 || s.position.X > screenWidth || s.position.Y < 0 || s.position.Y > screenHeight {
s.active = false
}
}
}
Here we create a new Shot, which has the positional and size attributes with the addition of an active
flag to determine if the shot should be drawn. We provide a Draw()
and Update()
method for rendering and moving the shot.
We need to create a slice to store our shots, then loop through them to update and draw them to the screen. So we start by creating some global state and some constants:
In the const
playerSpeed = 6.0
+ shotSpeed = 8.0
+ maxShots = 10
initialAsteroids = 5
In the var
player Player
gameOver bool
+ shots []Shot
)
We then initialise our slice in the init()
function:
asteroidRec = rl.Rectangle{X: tileSize * 1, Y: tileSize * 4, Width: tileSize, Height: tileSize}
+ // Create the shots
+ shots = make([]Shot, maxShots)
initGame()
and update the initGame()
to initalise the state:
asteroids = append(asteroids, createLargeAsteroid())
}
+ // Create the laser shots
+ for i := range shots {
+ shots[i].active = false
+ }
// Create the player
player = Player{
Now that we have our shots, we need to draw them and update them. Let’s update game loopsdraw()
to include the shots.
asteroids[i].Draw()
}
+ // Draw the shots
+ for i := range shots {
+ shots[i].Draw()
+ }
if gameOver {
And the game loops update()
:
asteroids[i].Update()
}
+ // Update the shots
+ for i := range shots {
+ shots[i].Update()
+ }
checkCollisions()
So we can now create, draw and update the shots when they are active. Let’s create a way to fire a shot:
In the player’s Update()
, we add the ability to shoot when the Space bar is pressed:
player.isBoosting = false
+ // Fire the lasers
+ if rl.IsKeyPressed(rl.KeySpace) {
+ fireShot()
+ }
// Accelerate the player with up
if rl.IsKeyDown(rl.KeyUp) {
Now we need the fireShot()
function:
func fireShot() {
for i := range shots {
// Find the first inactive shot
if !shots[i].active {
// Start at the players position
shots[i].position = player.position
shots[i].active = true
// Get the players direction
shotDirection := getDirectionVector(player.rotation)
// Get the initial velocity
shotVelocity := rl.Vector2Scale(shotDirection, shotSpeed)
// Account for the players speed
playerVelocity := rl.Vector2Scale(player.speed, player.acceleration)
// Fire the shot, relative to the players speed
shots[i].speed = rl.Vector2Add(playerVelocity, shotVelocity)
shots[i].radius = 2
// Break after one shot
break
}
}
}
To fire a shot, we loop through all the shots and find the first inactive one. We then set its position and direction to the same as the players. As the ship could be moving, we want to fire the laser at a constant speed relative to the ship. We do this by adding the player’s speed onto the speed of our shot, to give it relative speed, so it looks like the shots are always faster than the ship.
Let’s check our game to see if it is working by running go run main.go
Great, our ship now has weapons!
Though the lasers are not strong enough, they are passing right through the asteroids. Let’s add some shot collisions so we can destroy the asteroids.
The code for this section can be found on my GitHub Repo
Implement asteroid splitting
We want our asteroids to split when they are hit. To do this, we will need to spawn more asteroid objects when the collision between the shot and the asteroid occurs.
The asteroids should get progressively smaller and faster. The behaviour we will use is:
- Large -> 2 Medium
- Medium -> 4 Small
- Small -> Destroyed
Let’s start by adding a counter for how many asteroids we have destroyed:
func checkCollisions() {
for i := len(asteroids) - 1; i >= 0; i-- {
// Check for collision between player and asteroid
if rl.CheckCollisionCircles(
player.position,
player.size.X/4,
asteroids[i].position,
asteroids[i].size.X/4,
) {
gameOver = true
}
+ // Check for a collision between shots and the asteroid
+ for j := range shots {
+ // Loop through all the active shots
+ if shots[j].active {
+ // If it has collided with an asteroid
+ if rl.CheckCollisionCircles(
+ shots[j].position,
+ shots[j].radius,
+ asteroids[i].position,
+ asteroids[i].size.X/2,
+ ) {
+ // Destroy the shot and split the asteroid
+ shots[j].active = false
+
+ // The asteroid shot split according to our rules
+ splitAsteroid(asteroids[i])
+
+ // Remove the original asteroid from the slice
+ asteroids = append(asteroids[:i], asteroids[i+1:]...)
+
+ // Increase our score
+ asteriodsDestroyed++
+ break
+ }
+ }
+
+ }
+ }
}
We loop through all the shots and check if they have hit an asteroid; if they have, we split the asteroid.
We use a separate function that contains our splitting called splitAsteroid()
.
func splitAsteroid(asteroid Asteroid) {
// Do nothing for small
if asteroid.asteroidSize == Small {
return
}
// Work out how many splits to do
var newSize AsteroidSize
var split int
if asteroid.asteroidSize == Large {
newSize = Medium
split = 2
} else {
newSize = Small
split = 4
}
// Create the new smaller asteroids
for range split {
angle := float64(rl.GetRandomValue(0, 360))
direction := getDirectionVector(float32(angle))
speed := rl.Vector2Scale(direction, 2.0)
newAsteroid := createAsteroid(newSize, asteroid.position, speed)
asteroids = append(asteroids, newAsteroid)
}
}
We start by checking how big the asteroid is. If it’s small, we don’t need to do anything.
If it is large or medium, we set out many splits we need to do and the new size, then use the createAsteroid
function we created earlier to create a new asteroid.
We add that asteroid to the asteroids
slice so I can be rendered into our game.
Finally, we are taking note of how many asteroids we have destroyed. Let’s use this as our score.
In the draw()
function, let’s display the score:
// Draw the score to the screen
- rl.DrawText("Score 0", 10, 10, 20, rl.Gray)
+ rl.DrawText(fmt.Sprintf("Score %d", asteriodsDestroyed), 10, 10, 20, rl.Gray)
rl.EndDrawing()
Ok, moment of truth, let’s see if this is working. Run go run main.go
:
It is working!
We are hitting the asteroid, and it is splitting apart. Our score is increasing, and we need to start dodging the debris.
The code for this section can be found on my GitHub Repo
Introduce a Game flow
Finally, let’s make the game more playable. With a failure end state and a victory end state. We will also add a pause feature so players can stop the game.
Add two new states to var:
gameOver bool
+ paused bool
+ victory bool
shots []Shot
Set the initial state of the game in the initGame()
function:
func initGame() {
+ // Start with it not being game over, pauses or victory
+ gameOver = false
+ victory = false
+ paused = false
+
+ // Reset score
+ asteriodsDestroyed = 0
...
We need a way to detect when the state changes. The update
function is the perfect place for that:
func update() {
+ // If there are no asteroids left, we in
+ if len(asteroids) == 0 {
+ victory = true
+ }
+
+ // Toggle paused
+ if rl.IsKeyDown('P') {
+ paused = !paused
+ }
+
+ // Restart the game
+ if (gameOver || victory) && rl.IsKeyPressed('R') {
+ initGame()
+ }
+
+ // If it is not game over, update the frame
+ if !paused && !victory && !gameOver {
// Update the player
player.Update()
We call the initGame
function again when we want to restart, as that function resets all the state, it restarts our game for us.
Finally, we need to update the draw()
function to draw the UI for our players.
if gameOver {
drawCenteredText("Game over", screenHeight/2, 50, rl.Red)
+ drawCenteredText("Press R to restart", screenHeight/2+60, 20, rl.DarkGray)
+ }
+
+ if victory {
+ drawCenteredText("YOU WIN!", screenHeight/2, 50, rl.Gray)
+ drawCenteredText("Press R to restart", screenHeight/2+60, 20, rl.RayWhite)
}
// Draw the score to the screen
rl.DrawText(fmt.Sprintf("Score %d", asteriodsDestroyed), 10, 10, 20, rl.Gray)
+ pauseTextSize := rl.MeasureText("[P]ause", 20)
+ rl.DrawText("[P]ause", screenWidth-pauseTextSize-10, 10, 20, rl.Gray)
rl.EndDrawing()
Let’s look at the final result with go run main.go
:
The game now has four states: Running, Victory, Paused and GameOver.
The code for this section can be found on my GitHub Repo
Conclusion
Congratulations, We’ve built a full Asteroids-style game in Go using Raylib-go.
In this tutorial, we covered:
- Creating windows with Raylib-go
- Drawing textures and sprites from tilemaps
- Implementing player movement with keyboard input
- Adding physics-based acceleration and rotation
- Creating dynamic game objects (asteroids)
- Collision detection between game entities
- Implementing shooting mechanics
- Game flow management (pause, victory, game over states)
The complete source code is available on GitHub. Fork it, experiment with it, and share your improvements!
This project demonstrates how tools like Raylib make game development accessible while teaching fundamental concepts like collision detection, state management, and real-time rendering that apply across many domains.
If you found this useful, consider giving it a clap or sharing! You can also read this post on Medium.