Managing Groups of Things in Go: Part 1 Arrays and Slices

One of the most challenging things for me, in writing these posts, is to cover why something is important. Especially to someone that is just learning and doesn’t have a frame of reference to what we’re discussing. I think arrays and slices fit this challenge well. As conceptually we can cover what an array/slice is, we could even talk about how they are represented in the computer hardware, but until you see them being used they might just not mean much to a new learner.


Mar. 16, 2024 1524 words syntax ·

One of the most challenging things for me, in writing these posts, is to cover why something is important. Especially to someone that is just learning and doesn’t have a frame of reference to what we’re discussing. I think arrays and slices fit this challenge well. As conceptually we can cover what an array/slice is, we could even talk about how they are represented in the computer hardware, but until you see them being used they might just not mean much to a new learner. So we’ll start by talking about an actual problem in the game development world and hope that it makes sense when we’re done.

I’ve been teaching a game development course at a local library. We’ve been working along with the discussions that I’ve been writing here. But, what we haven’t been covering here is the game that we’re developing in the course. We’ve been working on a clone of the vintage sink sub game. Before you continue reading, take a couple of minutes to watch the Youtube video from the link above, if you’ve never played or even seen the sink sub game. We’re going to be referencing some things that are going on in this game to inform our conversation here.

You’ve watched the video right, right? Good. Now that you’ve watched the video, you’ll notice a few things. The ship above is dropping depth charges, the submarines below are shooting what look like naval mines towards the ship above.

To me, it seems like we have three different groups of things in the game.

Because each of these groups can contain one or more of the same thing, we can group them together and allow the game to operate on them at roughly the same time.

Before we do that, lets look at what a simplified struct for a depth charge might look like.

type DepthCharge struct{
	X, Y          float64
	Width, Height float64
	Speed         float64
	Sprite        *ebiten.Image
}

func (dc *DepthCharge) Update() error {
	dc.Y += dc.Speed
	return nil
}

func (dc *DepthCharge) Draw(screen *ebiten.Image) {
	opts := &ebiten.DrawImageOpts{}
	opts.GeoM.Translate(dc.X, dc.Y) // Moves the image to where it belongs on the coordinate system.
	screen.DrawImage(dc.Sprite, opts) // Draws our sprite image to the screen display.
}

func NewDepthCharge(x, y, width, height float64, sprite *ebiten.Image) *DepthCharge {
	return &DepthCharge{
		X: x,
		Y: y,
		Width: width,
		Height: height,
		Speed: 3,
		Sprite: sprite,
	}
}

Our DepthCharge struct has some properties:

There are also a couple of methods on DepthCharge. These two methods look a lot like the methods on the game interface used by the ebitengine game engine. There is nothing specifying that they must match, but it is a good practice to use in making sense of your game.

Also, there is a constructor function added to make it easier to create a new depth charge.

Keep all of this in mind, but it will be a bit before we get back to using our depth charge, we first need to discuss slices and arrays.

Intro to slices and arrays (and why I use slices)

Before showing the struct that we’re going to use, we discussed arrays/slices being groups of things. Other languages call the same thing a list. This gives us a way to have a collection of items, and loop over them. Think of our game and our struct above, instead of having a variable for each and every depth charge we’ll have (which would be very challenging because we would be limited to how many we could create ourselves, and we may always want more), we will have a slice of them which we can then work off of.

But you keep on saying slice and array what is the difference?

An array has a given size. Which gives it a maximum size, or number of items. It also is typed to the size, so you can’t pass different sized arrays as parameters. A slice does not have a given size. It can shrink or grow as needed. It also isn’t typed by its size like an array is, so a slice can be passed to any function that accepts a slice.

There are in actuality more technical differences between arrays and slices. However, for our purposes at this point, this will be enough to get us started.

How to declare arrays and slices

We’ve talked about slices and arrays. But what exactly do they look like in your code? Lets take a look. We’ll start with a simple example, rather than our depth charge.

package main

func main() {
	// Declaration of an array, of size 2, of type string
	var names [2]string
	names[0] = "John Doe" // Setting the first element of the array, notice it starts with zero
	names[1] = "Jane Doe" // Setting the second element of the array

	// Declaration of an array of size 2, and setting the values at declaration time
	ages := [2]int{56, 51}

	// Create a reference to a slice, and append to it
	var emails []string
	emails = append(emails, "john.doe@example.com")
	emails = append(emails, "jane.doe@example.com")
}

Working with slices

From above, we now have a way to create a slice and have a group of items that we can store an unknown amount of within our program. Usually, when we have this slice of items, we will want to be able to interact with all of them. This can be done with a loop (and in go this will be a for loop).

There are two main ways that a for loop looks like in go. There is the range for loop and the sentinel for loop. We could use either one, but I tend to prefer the range for loop. But let’s look at both of them below.

package main

import "log"

func main() {
	// Create a reference to a slice, and append to it, we'll show this one again as it matches more how we would use it within a game environment
	var emails []string
	emails = append(emails, "john.doe@example.com")
	emails = append(emails, "jane.doe@example.com")

	// For loop with the range semantics
	// This loop with create and index and element for each item in the slice. We can access them within the body of the for loop
	for idx, elem := range emails { // If you don't need the index variable, you can add an _ to omit it within the loop
		log.Println("Index:", idx, "Element:", elem)
	}

	// Sentinel semantics
	// This loop with directly index into the items of the array. You can see that via the [i] suffix on the slice variable.
	for i := 0; i < len(emails); i++ {
		log.Println("Index:", i, "Element:", emails[i])
	}
}

There are also a collection of methods that can be run on our slices. But they are not methods on the slice itself (as slices are pointers to memory with a capacity and size), they are a part of the slices package in the standard library. This package lets you sort, search, replace, and delete items from a slice. The syntax for these methods will look a bit verbose (lots of stuff), that is because they use the new generics added a couple version of go back.

An example

Let’s build out a more game related example of using this concept of slices. We’ll start with a game (as defined by the ebitengine interface), and use our depth charge from above to show how we would use this.

package main

import "github.com/hajimehoshi/ebiten/v2"

type Game struct {
	depthCharges []*DepthCharge
}

func  (g *Game) Update() error {
	// Looping through our depth charges and allowing them to run their own update logic
	for _, dc := range g.depthCharges {
		dc.Update()
	}

	return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
	// Looping through our depth charges and allowing them to draw
	for _, dc := range g.depthCharges {
		dc.Draw(screen)
	}
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
	return 1280, 720
}

// addDepthCharges could be passed to a player object, and that player object's update method could have a fire method that calls this (the passed in method)
func (g *Game) addDepthCharge(x, y float32) {
	g.depthCharges = append(g.depthCharges, NewDepthCharge(x, y, 20, 20, LoadEbitenImage("depth-charge.png")))
}

func main() {
	ebiten.SetWindowSize(640, 480)
	ebiten.SetWindowTitle("Hello, World!")
	if err := ebiten.RunGame(&Game{}); err != nil {
		log.Fatal(err)
	}
}

func LoadEbitenImage(name string) *ebiten.Image {
	// Omitting this method for brevity
}

What next?

This has just been a general introduction to slices/arrays. Trying to cover as much why they are important as how they work. The game that I’m working on with my class, has more details with regards to how we’re using these concepts in a more integrated game setting. You can check it out at this github repo.