Skip to content

Level 0 Go Tutorial

A note on Go

Go is a language developed by Google. It's a fast, compiled language thats geared towards concurrent workflows and web applications.

For this reason, we use it for the backend of our ground control station.

1 "Hello World" Program

Let's start by making sure that you have a working Go development environment set up to run a Hello World program.

1.1 Installing Go

To compile Go source code, you will need to download the Go compiler and it's associated utilities.

There are two ways we recommend installing Go.

  1. Binary Distribution
  2. via a Go version manager

To download the binary distribution of Go, download the version for your operating system and architecture from here and follow the installation instructions here.

A Go version manager can help you manage your Go installation and switch between multiple Go versions.

Here is one that we recommend, but you're free to use your own.

If you use g, make sure to install a version of Go.

You can install the latest like so:

g install latest

Or a specific version:

g install 1.21

Once Go is installed, verify with the following command:

go version

1.1.1 Installing Go Editor and Tools

You're free to use the text editor or IDE of your choice. However, you might want tools such as autocompletion and syntax highlighting to help you during development. We have a few recommendations when it comes to working with Go.

VS Code

VS Code is a text editor developed by Microsoft that is a popular choice among many developers. While it doesn't come with built-in support for many languages, it can be extended with community based extensions from the VS Code Marketplace. It is a great choice if you want a single editor no matter what language you are working with.

While, VS Code itself doesn't come built with Go support, it can easily be obtained via an extension.

First install VS Code from here.

Open VS Code and click on the Extension Marketplace Button on the left sidebar:

marketplace

Search for Go and install the first one from the "Go team at Google".

go-extension

Once it's installed, you should have syntax highlighting, autocompletion and much more all setup.

Goland

Goland is a full IDE developed by Jetbrains with first class support for Go. If you've used other Jetbrains products such as Intellij IDEA for Java, you may already be comfortable with using Goland.

Goland is a paid IDE. Pricing is available here and you can download it from here.

However, you may be eligible for a free copy of Goland if you register using your UCSD email address. More information about this program can be found here.

Vim/Neovim

Vim and Neovim are lightweight, terminal-based text editors with. Similarly to VS Code they have no built-in Go tools. Getting Vim/Neovim setup for Go is a bit more involved than installing an extension for VS Code. So if you want something that just works as soon as possible, go with VS Code.

You can add Go support with various extensions. This extension provides Go support for Vim. For neovim, you can setup a language server that supports Go. This is one extension that will get you up and running relatively quickly. There are also distributions of Neovim that come built in with Go support. LunarVim is one of those distributions that comes built in with Go support.

1.2 Running a Go Program

As mentioned, Go is a compiled language so we can build an executable file that will run natively for our specific architecture and operating system.

Here's a Go "Hello World" program. It'll just be presented here and will be explained in just a second. Create a file called main.go with the following contents:

main.go
package main

import "fmt"

func main() {
    fmt.Println("Hello World!")
}

Once the file has been created and saved, enter your terminal and run the following command to build the executable file:

go build main.go

You should now see the executable file that was created. Run ls to verify.

ls
main  main.go

You can run the binary via your shell as so:

./main
Hello World!

If you got to this stage, you have successfully set up a development environment for Go.

There is another way you can run your Go programs. It involves the go run command.

go run main.go

go run will compile and run the program defined in the specified file. If you try to run go run main.go, the "Hello World" program should run just the same.

1.3 Explaining Hello World

Now that we have a working "Hello World" program, let's figure out how it works.

If we go line by line in the program, the first line looks like this:

package main

This is where we define the package that our project belongs to. Packages are how we organize components of our project. We can group together functions, types, constants, and more. We can also group together multiple Go source code files into a single package.

The main package contains the entrypoint of our program.

The next line of code looks like this:

import "fmt"

This is an import statement that imports the fmt package. The fmt package includes handy functions that help us with text formatting and outputting text to the terminal. The fmt package is provided by the Go standard library. We can import a package from the standard library, from a 3rd party library, or our own code. By importing the package, we can access it's functionality from our program.

The next line looks like this:

func main() {

This is the start of where our main function is declared. This line specifically is the function header (also called function signature). main is a special function which is the entrypoint of our program. When we run an exectable, whatever is in main gets executed first. If you've worked in languages such as Java you might be familiar with declaring a main method with the signature of public static void main(String[] args). The main function in Go works just the same and must be declared for any Go program.

If you're not familiar with functions from another language, they are blocks of code that perform a task. They are defined somewhere and can be reused multiple times. They can take zero or more inputs and produce an output. We'll get to talking about how functions are defined in Go later on.

Inside the body of our main function, we have this line:

    fmt.Println("Hello World!")

This is the real meat and potatoes of the "Hello World program". We are calling the Println function that is defined within the fmt package. Println will take in any number of inputs, also known as arguments, and display them to the terminal. In this case, we only provide a single argument: "Hello World!".

This argument, "Hello World!", is a type of data called a string. If you're familiar with strings from another language, you should already be familiar with how they work in Go. If not, strings are types of data that allow us to store text. Strings are just a sequence of characters. When repsresenting a string in Go, we wrap it in double quotes. Here we have created the string with the value of Hello World! and passed it as an argument to our call to fmt.Println.

The last line in our program is a closing curly brace to signify the end of the declaration of the main function.

}

And with that, we've seen how a simple Go program works.

2 Go Basics

2.1 Variables

Variables can be thought of as containers that can store some peice of data. We can reference the underlying data of a variable using it's variable name.

Variables can store an assortment of data types. We'll get to talking about most of them in the next section.

2.1.1 Declaration

In Go, there are two main ways of declaring variables.

The first is a bit more verbose and requires that you specify the type of the variable in question.

var x int = 5
In this case, we declared a variable named x, of type int (integer) with a value of 5.

You can verify this by printing the variable out after declaration.

var x int = 5
fmt.Println(x)

We also technically don't need to specify the value itself. We could also declare x like so:

var x int
In this scenario, the natural question to ask is "What value does x have?". If we don't specify a value, the variable takes on the zero value associated with it's type. For some types, like int, the zero value is obvious. In this case, the zero value is just the numerical value of 0. For other types, it might not be as obvious so we'll talk about that later. In general, you should try to declare your variables with concrete values wherever possible. It can avoid any potential ambiguity and make your code easier to work with.

Once again, you could verify by printing out the value of the variable.

var x int
fmt.Println(x)

The other syntax of declaring a variable is a bit more succinct and doesn't require the type of the variable to be specified. Instead, the compiler will infer what the type of the variable should be based on the assignment.

The syntax looks like this:

x := 5
We use the := operator to accomplish this. This is sometimes referred to as the walrus operator.

2.1.2 Assignment

Now that we've seen a few ways of declaring variables, let's talk about updating the value of a variable.

One can do so with the = operator.

x := 5
fmt.Println(x)
x = 6
fmt.Println(x)

While these two lines might seem very similar, it is important to understand the slight difference. In other languages, you just use the = operator to both declare and update variables. This will be familiar to you if you have programmed in a language like Java, C, or Python. However, in Go, you are allowed to declare variables without explicitly declaring their type. Therefore, in order to be more explicit that you are defining a new variable x with an initial value of 5, rather than trying to update a variable x that already exists, you have to use the walrus operator instead of the normal assignment operator. It is mainly a stylistic choice, i.e. the language could have been implemented without the walrus operator because the compiler would be able to tell if you were declaring a new variable rather than updating an old one, but this syntactical distinction forces you the programmer to understand the difference, which can help prevent errors. You will probably forget to do this at first, but eventually it will become second nature.

2.1.3 Unused Variables

One quirk of Go that might be quite annoying at first is the matter of unused variables. If you have a variable which is not used after declaration, the program will not compile.

Take this simple block of code:

package main

func main() {
    x := 5
}
Compiler output
./main.go:4:5: x declared but not used

To resolve this compiler error, you can either use the variable, comment it out, ignore the variable, or delete the line.

package main

func main() {
    x := 5
    fmt.Println(x)
}
Comments in Go require two forward slashes. Any code after that point will be ignored by the compiler.
package main

func main() {
    // x := 5
}

You can also ignore the variable by renaming it to _ (called the blank identifier).

package main

func main() {
    _ = 5
}

2.2 Data Types

2.2.1 Integers

Integers are any whole number. Go has several ways of representing integers.

Signed

The int type is one way that Go represents signed integers. Signed integers allow us to store both negative and positive integers.

var x int = -100
fmt.Println(x)

Go has various signed integer types that allow us to represent integers of different sizes.

Type Range
int8 -128 to 127 -27 to 27-1
int16 -32,768 to 32,767 -215 to 215-1
int32 -2,147,483,648 to 2,147,483,647 -231 to 231-1
int64 -9,223,372,036,854,775,808 to

9,223,372,036,854,775,807
-263 to 263-1
int Platform dependent. Same as int32 for

32-bit systems and int64 for 64-bit systems.
Unsigned

Go represents unsigned integers using the uint type. Unsigned integers only allow us to store non-positive integers.

var x uint = 70
fmt.Println(x)
Type Range
uint8 0 to 255 0 to 27-1
uint16 0 to 65,535 0 to 215-1
uint32 0 to 4,294,967,295 0 to 231-1
uint64 0 to 18,446,744,073,709,551,615 0 to 263-1
uint Platform dependent. Same as uint32 for

32-bit systems and uint64 for 64-bit systems.
Operators

You can apply several arithemtic operations to integers in Go.

x := 0
x = x + 10
x = x - 2
x = x * 4
x = x / 2

If the result of dividing an integer is a non-whole number, the decimal component will be chopped off (or truncated) and the result will be only the integer component.

var x int = 5
x = x / 2
fmt.Println(x)
Output
2

There is also another operator, %, called the modulo operator. The modulo operator will return the remainder of division between two numbers.

var x int = 5
x = x % 2
fmt.Println(x)
Output
1

2.2.2 Floats

Go has the float32 and float64 types which allows us to store non-whole numbers with a decimal point.

var x float64 = 24.58
fmt.Println(x)

Most integer arithmetic operations apply to floats (except for modulo).

2.2.3 Booleans

Booleans allow us to store either true or false. Go provides us the bool type to represent this data.

var x bool = true
fmt.Println(x)

We can negate boolean values with the ! operator.

var x bool = true
fmt.Println(x)
x = !x
fmt.Println(x)
Output
true
false

Boolean values can also show up when evaluating comparison statements. Go has the following comparison operators: ==, !=, <, >, >=, and <=.

x := 2 == 2
fmt.Println(x)
y := 2 > 3
fmt.Println(y)
z := 2 <= 3
fmt.Println(z)
Output
true
false
true

2.2.4 Strings

As mentioned previously, strings allow us to store a sequence of characters.

We define strings as text within double quotes.

x := "this is my string"
fmt.Println(x)
You can also apply some operators to strings.

To check for equality between two strings:

x := "hi" == "bye"
fmt.Println(x)
Output
false

You can also use the + to concatenate two strings together.

x := "Hello " + "there"
fmt.Println(x)
Output
Hello there

2.2.5 Printf detour

While this isn't a data type, we can use our knowledge of some simple data types to explain how string formatting works.

Go includes a function in the fmt package called Printf which is useful for formatting how variables are printed out alongisde text and other values.

Let's say I wanted to print out the current temperature and I had the temperature stored in a variable. We can do so with the Printf function.

temp := 82
fmt.Printf("The temperature is %d degrees today", temp)

Let's figure out what's going on here.

As mentioned with Println, we provide arguments to this function call. In this case, the first argument has different behavior than all the other ones. The first argument outlines the format specifier which determines how the output string should look like.

Here we define our format specifier as "The temperature is %d degrees today" where we have our message, this weird %d thing, and the end of our message.

The %d is one example of a formatting verb. This one specifically prints out a base 10 integer. Check out here for a complete list.

Here's a few to note:

  • %s for strings

  • %f for floats

  • %v where Go will infer the verb from the type

The rest of the arguments determine what values should go in the spots where we placed our verbs. The next argument will match up with the first verb, the argument after that will be placed in the spot of the second verb and so on.

Here's an example with two verbs:

temp := 66
rainfall := 3.2
fmt.Printf("The temperature is going to be %d degrees and it will rain %f inches", temp, rainfall)

Play around with Printf and try to print out some other data types using the other types of verbs.

Another function that makes use of the same string formatting directive is fmt.Sprintf. Unlike fmt.Printf, it doesn't print the result out but creates a string with the result.

var message string
message = fmt.Sprintf("Today I am %d years old", 20)

2.2.6 Arrays

Arrays in Go represent fixed-size collections of values. The number of values must be known at compile time. For a more flexible, growable collection see Slices.

Here we declare an array of integers with 5 elements:

x := [5]int{1, 2, 3, 4, 5}
Here's an array of 2 strings:
x := [2]string{"Hello", "there"}

Notice how the number inside the square braces represents the number of elements. Right after that we specify the type of elements we want to store. Notice that arrays must have elements of the same type. There is more you can do with arrays but we'll go over them when we discuss Slices

2.2.7 Slices

Slices are collections of values where the number of elements can be resized. Similarly to arrays, they must have elements that are all of the same type.

Here's a slice of strings:

x := []string{"this", "is", "a", "slice", "of", "strings"}
fmt.Println(x)
Notice how we don't need to specify a size for the slice.

The main difference is that we can now grow our slice at runtime. We can do so with the built in append function.

x := []string{"this", "is", "a", "slice", "of", "strings"}
x = append(x, "!")
fmt.Println(x)
append will not change the underlying slice, but will return a new slice that includes the new elements. We'll get to talking more about returning in the section on Functions .

If we want to create a new slice, there are a few ways of doing so.

Like you saw just now, we can declare a slice of literal values. To create an empty slice, we can just declare no literals:

x := []string{}
fmt.Println(x)

We can also create a new slice with the built in make function. make takes three arguments: type, capacity and length.

Here's we can create an empty slice.

x := make([]string, 0, 0)
The type in this case is just []string which represents a slice of strings.

The length is how many elements the slice should contain.

The capacity is how large the underlying array is. Let's quickly talk about how slices work under the hood. Beneath every slice, there is a fixed-sized array that stores all the values. This array is abstracted away from the programmer and you usually don't have to think much of it. When the slices grows beyond the size of the underlying array, the data is automatically copied to a larger underlying array. If you've worked with Java, this is how the Java ArrayList is also implemented. So, when we specify the capacity, we specify how large the underlying array should be. If you know that your slice will need quite a bit of memory, specifying capacity can be useful to avoid more memory allocations later in the program.

While length and capacity sound similar, they are not always the same value. If you keep growing a slice, the capacity might be increased by a large factor to avoid having to copy the data over many times. In this case, the slice has a capacity larger than the number of elements.

The make function can also be called without the capacity option like so:

x := make([]string, 0)
This just sets the length of the slice to the specified value.

There are two built in functions that allow us to find the length and capacity of slices: len and cap.

Here's how they work:

x := make([]string, 5, 10)
length := len(x)
capacity := cap(x)
fmt.Printf("slice x with length %d and capacity %d\n", length, capacity)

Now that we know how to create slices and add new elements to them, let's actually access the values stored in them.

One way of doing so is with index operations.

Similar to other languages, we can fetch the value at a specific index (position) in the slice. Go is 0-indexed, meaning that we start counting the index from 0. So the first element has an index of 0, the second has an index of 1 and so on.

The notation for indexing is square brackets with the index number inside them.

x := []int{23, 64}
first := x[0]
fmt.Printf("first element %d\n", first)
second := x[1]
fmt.Printf("second element %d\n", second)

With slices, we can also retrieve ranges of items. If we place a colon between two index numbers, we can get a slice of elements that are within those indices. The notation looks like [a:b] where a is the start of the sub-slice (inclusive) and b is the end (exclusive).

This example will print out the second and third elements.

x := []string{"first", "second", "third", "fourth"}
fmt.Println(x[1:3])

We can also get elements starting from the start of the slice or all elements until the end of the slice. This is done by omiting one of the indices. So the notation would be [:n] for the first n elements and [n:] for all the elements starting from n.

x := []string{"first", "second", "third", "fourth"}
fmt.Println(x[:3])
fmt.Println(x[1:])

Check out this article for more information on slices and how they work.

2.2.8 Maps

Maps are another type built into Go. They allow us to store key, value pairs where all the keys are unique from each other. If you've worked with a hash map in another language, they work the same as in Go.

We can create a map using the built in make function.

x := make(map[string]int)

Notice the type signature of the map (map[string]int). The type inside the square braces represents the type of the keys. The type after is the type of the values. This means that we just created a map with string keys and int values.

Every key is associated with a single value. We can assign a new key value pair like so:

prices := make(map[string]int)
prices["pizza"] = 5

We can retrieve the value associated with a key using similar notation.

pizzaPrice := prices["pizza"]

If the value doesn't exist, the zero-value of the map's values are returned. So in this case, if we tried to get a value of type int with a key that doesn't exist, 0 will be returned.

To check if a key is within a map, you can use the following notation:

sodaPrice, ok := prices["soda"]
fmt.Println(sodaPrice)
fmt.Println(ok)
Try running this code without inserting the "soda" key and run it after adding the key. You'll notice that the ok variable returns a boolean that tells us if the key was in the map. This uses something in Go called multiple return values that we'll talk about soon.

2.3 Functions

We've already seen the main function in Go. So let's now look at how functions generally work.

If we're making our own function we must declare it. We need to define a function signature and a function body. The signature will define the function name, what inputs a function should take, and what it should return. The function body contains the actual code of the function.

Let's define a simple function to add two numbers together and analyze it.

func add(x int, y int) int {
    sum := x + y
    return sum
}

We start by declaring our function and giving it the name add. We can name our function whatever we want (as long as it doesn't conflict with our other functions or any language keywords).

The next part is wrapped in a pair of parenthesis. This is where we define the parameters to our function. These are the inputs to our function. We can define any number of parameters. For each parameter, we must give it a name and a type. Here, we have defined two parameters. One is named x and the other is named y. Both are defined as having type int.

The next part of the signature is the return type. The return type determines what type the output of the function should have. Here we have determined that our function should return an int. This makes sense since the result of adding two integers is a single integer.

Next, we have opening curly braces that signify the start of our function body. Inside the function body, we implement our adding functionality. We compute our addition and store it in a sum variable. Then we return the sum we computed using the return keyword. The return keyword allows us to specify what value the function should output. The return value must match the return type we specified in the function signature. One thing to note is that when we reach a return statement, the function will stop and return the return value to whoever called it.

When it comes to return types, we don't have to only return a single value. We can return zero or more values.

Here's a function with no return values:

func hello() {
    fmt.Println("hello")
}

Here's a function with more than one return value (in this case, two integers):

func div(dividend int, divisor int) (int, int) {
    quotient := dividend / divisor
    remainder := dividend % divisor
    return quotient, remainder
}

Now that we know how functions are declared, let's use them.

As mentioned when talking about fmt.Println, the way to call a function is to specify it's name followed by parenthesis. We can specify any number of arguments inside the parenthesis.

Let's say we wanted to call our hello function earlier. This function has 0 arguments and 0 return values.

hello()

Let's call our add function with 2 arguments and 1 return value.

result := add(2, 3)
fmt.Println(result)

Let's call our div function with 2 arguments and 2 return values. div returns both the quotient and the remainder when divig two integers. Note that we can destructure the two return values using a comma notation.

quotient, remainder := div(10, 3)
fmt.Println("quotient: %d, remainder: %d\n", quotient, remainder)

Let's say we didn't care about the remainder, but still wanted the quotient. We can ignore the remainder by using the _ symbol (called the blank identifier).

quotient, _ := div(10, 3)
fmt.Println("quotient: %d\n", quotient)

2.4 Structs

Structs are another way of collecting data. Unlike slices and arrays, they don't need to contain values of the same type. We also can't retrieve values by index. Instead we refer to values of a struct with a given name.

Here's how a struct type is defined:

type food struct {
    name string
    price float64 
}

Here we've declared that any kind of food struct should have name and price fields.

Let's create an instance of this food struct to represent some actual food item.

salad := food{name: "salad", price: 8.50}
If we want to retrieve fields from the struct, we can do so with a dot notation. Simply, write the name of the struct followed by a dot which is followed by the field name.

fmt.Println("%s with price %f\n", salad.name, salad.price)

Structs can also have functions defined on them called methods. These methods can access struct fields and can only be called from an instance of a struct. These are similar to how methods work for classes in Java.

func (f food) buy() {
    fmt.Printf("just bought a %s for %f\n", f.name, f.price)
}

If we wanted to call this function we would use this syntax:

salad := food{name: "salad", price: 8.50}

salad.buy()

2.4 Control Flow

2.4.1 If-Else

Just like in most other languages, Go has if statements. In Go, you don't need to specify parenthesis around the condition. The condition can be any boolean or any expression that evaluates to a boolean.

if true {
    fmt.Println("this is true")
}
if 2 < 3 {
    fmt.Println("two is less than three")
}
if !(1 == 2) {
    fmt.Println("two does not equal one")
}

We can also specify code to run if the condition in the if statement evaluates to false.

if 3 > 2 {
    fmt.Println("three is greater than two")
} else {
    fmt.Println("three is not greater than two")
}

You can also specify multiple conditions to check against. Whichever condition evalutes to true first will have it's block evaluated. If none of the conditions are true, the else block is evaluated.

num := 5
if num < 2 {
    fmt.Println("num less than 2")
} else if num < 10 {
    fmt.Println("num greater than 2 and less than 10")
} else {
    fmt.Println("num greater than 10")
}

2.4.2 Loops

Loops are useful for repeating the same operations over and over again. Go has only one type of loop for. Other languages might have other types like for-each, while, while-do. However, for is flexible and can do anything other loops can.

The most basic type of for loop is one without a condition:

for {
    fmt.Println("hello")
}

This loop will keep looping infinitely and will never terminate. Let's define some loops with a condition.

isDone := false

for !isDone {
    fmt.Println("finishing task")
    isDone = true
}

This loop will continue running until the condition is false. In this case, the condition evaluates to false after a single iteration of the loop.

We can also write standard C-style for-loops like so:

for i := 0; i < 10; i++ {
    fmt.Println(i)
}

This loop will loop ten times. We define our iterator variable to keep track of which iteration we're on (in this case i). We define a terminating condition, so when this condition is false the loop will end (i < 10). We then define how the iterator variable should change on every iteration (i++). This ++ syntax means that we should increment the value of i by 1.

This loop could be rewritten with the previously mentioned style:

i := 0
for i < 10 {
   fmt.Println(i)
   i++ 
}

If we want to stop a loop before it's terminating condition is true, we can use the break statement.

for i := 0; i < 10; i++ {
    if i == 5 {
        fmt.Printf("i is %d, leaving loop now\n", i)
        break
    }
    fmt.Println(i)
}

We can also skip the rest of the loop body and just move onto the next iteration with the continue statement.

for i := 0; i < 10; i++ {
    if i % 2 == 0 {
        fmt.Println("skipping even numbers")
        continue
    }
    fmt.Println(i)
}

One more useful application of for-loops is the ability to iterate over slices and maps.

Here's an example of us printing out every value in a slice.

x := []int{1, 2, 3}
for i, val := range x {
    fmt.Printf("idx %d has val %d\n", i, val)
}
We use the range statement to iterate through the values of the slice. Notice how we have access to the index our iteration is currently on (the variable i in this case). We also have the value at the current index (val in this case).

This type of iteration might be useful if we wanted to compute the sum of all numbers in a slice.

x := []int{1, 2, 3}

sum := 0
for _, val := range x {
    sum = sum + val
}
fmt.Printf("sum is %d\n", sum)

We can also iterate through the keys and values of a map.

x := make(map[string]int)
x["one"] = 1
x["two"] = 2
x["three"] = 3

for key, val := range x {
    fmt.Printf("key %s: val %d\n", key, val)
}

2.4.3 Error Handling

While error handling isn't it's own type of control flow (like if-else or for), it's ubiquitious in all Go programs that it deserves to be focused on.

When a function is executing, it may run into issues and not be able to complete it's task. In these cases, we want to exit the function early and report the error to the caller. The standard Go way of doing so is to simply return the error have the caller check it.

Go provides an error type to indicate if an error has occured. At a high level, an error value is just a container for some error message.

One way of creating an error is with the fmt.Errorf function.

err := fmt.Errorf("uh oh an error happened")

Let's say we wanted to create a square root function and we want to consider it an error if a negative number is given as an argument.

We can check if the input is negative and return an error if so.

func sqrt(x int) (int, error) {
    if x < 0 {
        return 0, fmt.Errorf("cannot get square root of negative number")
    }

    // implementation
    // ...
    return result, nil
}

Notice how the return type changed from int to (int, error). This is using the multiple return values idea that we talked about earlier. The first number is the result of the square root operation.

If x is negative, we return an error. Notice how we still have to provide a value to return for the result. The convention in Go is to ignore the first value if an error is present in the second value. This will make more sense when we look at handling errors from the caller's perspective.

Let's say we want to use our square root function. We would call it like so:

result, err := sqrt(-4)

Ok now we have a result integer and error. What should we do with them?

We need to check the value of the error. This will tell us if it's safe to read the value of result.

The convention for checking an error in Go is to check if it is equal to nil or not. nil is just Go's way of representing a value of nothing. This is analogous to null in languages like Java and None in Python.

Checking if an error is not nil is as simple as an if-statement:

if err != nil {
    ...
}

So if we wanted to check the error of sqrt we could write something like this:

result, err := sqrt(-4)
if err != nil {
    fmt.Printf("unable to calculate square root; %s\n", err.Error())
    return
}

fmt.Printf("square root is %d\n", result)
// more computation ...

Notice that inside the if statement we handle the case where an error is present (where the error is not nil). This allows us to log the error and exit early (return statement). It makes sense to exit early after seeing the error since we cannot continue executing our program with an invalid square root result. Since we exit early, the rest of the code can safely use result and assume that it is a valid value. Notice how we can just print out the value of result in the last line. We already handled any potential error and now are left with a valid result.

Generally exiting early on an error is a good practice with regards to code style. Let's add a call to another function and look at how our code would look if we didn't exit early.

Instead of exiting early, let's place the error handling in the else block and the good path in the initial if block.

result, err := sqrt(-4)
if err == nil {
    fmt.Printf("square root is %d\n", result)

    newResult, err := div(result, 2)
    if err == nil {
        fmt.Printf("division is %d\n", newResult)
    } else {
        fmt.Printf("unable to calculate division; %s\n", err.Error())
    }
} else {
    fmt.Printf("unable to calculate square root; %s\n", err.Error())
}

While this code is functionally fine, it means that any future computation we want to do with our results will require us to add another level of nesting to our code. This increase in nesting can make our code harder to read and follow. While this example is quite straightforward, you can imagine this getting out of hand with larger, more complex functions.

Let's see how this code would work if we used early exiting.

result, err := sqrt(-4)
if err != nil {
    fmt.Printf("unable to calculate square root; %s\n", err.Error())
    return
}

fmt.Printf("square root is %d\n", result)

newResult, err := div(result, 2)
if err != nil {
    fmt.Printf("unable to calculate division; %s\n", err.Error())
    return
}
fmt.Printf("division is %d\n", newResult)

This code is easier to read since we can easily see where function goes wrong and we hit an error. It's easier to spot since the scope for error handling deviates from the rest of the code. Also, the main flow of the program where we don't hit an error is in the top-most scope. This means that we can clearly follow how the main path of execution should look like.

3 Hangman

Now that we're familiar with some of the fundamentals of Go, let's make a simple Hangman game.

Let's start off with an empty main function.

package main

func main() {
}

3.1 User input

One of the things we need for a hangman game is to read in guesses from the player. We can do this by reading in user input text.

The fmt package provides functions like Scanln and Scanf which can read in user input until they hit the enter key.

We will be using Scanln to read in some user input.

package main

import (
    "fmt"
    "os"
)

func main() {
    var input string
    _, err := fmt.Scanln(&input)
    if err != nil {
        fmt.Printf("invalid user input; %s\n", err)
        os.Exit(1)
    }
    fmt.Println(input)

}

Let's break down how this works.

var input string
We declare an empty string variable where our input data will be placed.

_, err := fmt.Scanln(&input)
We call fmt.Scanln to read all input until the Enter key is pressed. We pass it an argument that looks like &input. We haven't really talked about what the & sign means. We won't really need it for the rest of this tutorial but let's quickly go over it right now. The & symbol allows us to get a pointer to a variable. All variables are just some data stored in memory. Every spot in memory has an address that allows the program to access it. Getting a pointer to a variable is just getting the address where that variable is stored. Here, we are telling Scanln to put any user input in the memory address where our input variable is located at.

You'll also notice that fmt.Scanln returns two values. The first is the number of items successfully scanned. The second is an error value that tells us if something went wrong. You can read more about Scanln, Scan, and Scanf on the official Go doc page.

if err != nil {
    fmt.Printf("invalid user input; %s\n", err)
    os.Exit(1)
}
This next section has us perform some error handling on the error that is returned from Scanln. Read the section on error handling to understand how the Go convention works.

Inside the if block, we print an error message and exit the program.

fmt.Println(input)
Finally, we print out the input we received. Try running this program, enter some input, hit the enter key, and see what gets printed out.

Let's add some prompts to instruct our user on what to do.

package main

import (
    "fmt"
    "os"
)

func main() {
    fmt.Println("Welcome to Hangman!")

    fmt.Printf("Make a guess: ")
    var input string
    _, err := fmt.Scanln(&input)
    if err != nil {
        fmt.Printf("invalid user input; %s\n", err)
        os.Exit(1)
    }
    fmt.Printf("You guessed: %s\n", input)
}

3.2 Repeating Input

Let's think about how our hangman game should work. We will have some word that the user must guess. The user will them try to guess the word via one of two ways:

  1. Guess one character at a time until they've run out of guesses
  2. Guess the entire word outright

Let's try to handle the second case and only check if the user has guessed the entire word correctly.

The first thing we need to do is to keep asking for new inputs. To keep asking for new inputs, we can wrap our input code in a for loop.

package main

import (
    "fmt"
    "os"
)

func main() {
    fmt.Println("Welcome to Hangman!")

    for {
        fmt.Printf("Make a guess: ")
        var input string
        _, err := fmt.Scanln(&input)
        if err != nil {
            fmt.Printf("invalid user input; %s\n", err)
            os.Exit(1)
        }
        fmt.Printf("You guessed: %s\n", input)
    }
}

If you run this code, you should be able to keep making guesses. However, the program will never end (unless you kill it with Ctrl-C). Let's add some condition to break out of the loop. One such condition would be to guess the correct word. If we guess the correct word, the game is over and we should exit.

package main

import (
    "fmt"
    "os"
)

func main() {

    wordToGuess := "hello"

    fmt.Println("Welcome to Hangman!")

    for {
        fmt.Printf("Make a guess: ")
        var input string
        _, err := fmt.Scanln(&input)
        if err != nil {
            fmt.Printf("invalid user input; %s\n", err)
            os.Exit(1)
        }
        if input == wordToGuess {
            fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
            return
        }
    }
}

Let's also add another condition for the loop to terminate: a maximum number of guesses. If the user goes over the maximum number of guesses, the program should quit.

package main

import (
    "fmt"
    "os"
)

func main() {

    wordToGuess := "hello"
    guessesLeft := 5

    fmt.Println("Welcome to Hangman!")

    for guessesLeft > 0 {
        fmt.Printf("Make a guess (%d left): ", guessesLeft)
        var input string
        _, err := fmt.Scanln(&input)
        if err != nil {
            fmt.Printf("invalid user input; %s\n", err)
            os.Exit(1)
        }

        guessesLeft--

        if input == wordToGuess {
            fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
            return
        }
    }

    fmt.Println("You ran out of guesses and lost!")
}

Here we've declared guessesLeft to start off as 5. On every loop iteration, we will check that we have any guesses left with the condition guessesLeft > 0. Inside the loop block, we will decrement the number of guesses left with the line guessesLeft--. The -- is the post-decrement operator that will decrement an integer by 1 and update it's value.

Now we only have 5 guesses to guess the correct word. If we run out of guesses, guessesLeft will be 0 and we will stop iterating through the loop. We will then end up outside the loop block where we print the message "You ran out of guesses and lost!".

3.3 Keep track of guesses

Now let's handle the other case that we mentioned above.

  1. Guess one character at a time until they've run out of guesses

To know if we have guessed the entire word, we need to keep track of which guesses we've made before. You can think of it as a history of guesses.

There are many ways of doing this. We could create a slice and have it grow with every new guess we make. This could work and we could loop through the slice to check if we've guessed a certain character yet. However, this might not be the most ideal solution.

There are two properties which might be desirable when choosing a data structure to store these guesses:

  1. Uniqueness: We don't want to store duplicate guesses more than once.
  2. Fast lookups: We want to quickly check a given character.

Luckily for us, there is a built in data structure that satsifies both of these constraints: the Go map type.

As mentioned in the section on maps, they will store key-value pairs where each key is unique. We can store a unique set of guesses if we make them the keys of our map.

Maps also give us fast lookups. We won't spend too much on this since it requires explaining things such as time complexity. However, you should know that looking up a key in a map is a quick operation. It is much faster than iterating through a slice and checking for equality on each element of the slice.

With that said, let's set up our map. We already mentioned that the key should represent each of the user's guesses. The question that remains is: what should the map values represent?

Well, there's really nothing else we need to store. We want to store multiple unique guesses, and that's it. So in this case, it makes sense to put a placeholder for the values of the map. We can choose any type as the placeholder since we're never going to be accessing it. I'll choose a bool.

prevGuesses := make(map[string]bool)

This is one of the quirks of Go when compared to other languages. Other languages (such as Python and C++) have built in set types. Go doesn't have such types since the map type can already perform the same functionality. You may see this style of thinking with other parts of the language. For such trivial things, the language designers feel that the user can quickly reimplement them instead of adding more features to the language's core libraries. For example, there is no Contains function to check if a value is within a slice or there was no min and max function up until Go 1.21. If you wanted to avoid this extra bool map value, you could create a Set struct that hides the bool value from the programmer using the Set. The Set would have an underlying map and the Set would have methods that would update and check for for element membership within that map. If you want to try to make something like this, go for it! For the purposes of this tutorial we'll just deal with the awkard bool.

Let's place this map declaration at the top of our main function.

Let's also add in some code to populate the map with every new guess.

package main

import (
    "fmt"
    "os"
)

func main() {

    wordToGuess := "hello"
    guessesLeft := 5
    prevGuesses := make(map[string]bool)

    fmt.Println("Welcome to Hangman!")

    for guessesLeft > 0 {
        fmt.Printf("Make a guess (%d left): ", guessesLeft)
        var input string
        _, err := fmt.Scanln(&input)
        if err != nil {
            fmt.Printf("invalid user input; %s\n", err)
            os.Exit(1)
        }

        guessesLeft--

        if input == wordToGuess {
            fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
            return
        }

        if len(input) > 1 {
            fmt.Println("You did not guess the word correctly! Try again")
            continue
        }

        prevGuesses[input] = true
    }

    fmt.Println("You ran out of guesses and lost!")
}

Notice that we also added a new check. If the user didn't guess the word outright and tried to type out more than a single character, we print a message and tell them to retry. This ensures that we only place single character guesses into the map.

We also insert the guess into the map and give the key a value of true. If you recall, this bool is just a placeholder and it's value doesn't matter. We could've inserted a value of false and the behavior would've been the same.

Let's add another check to print a message if the user has already guessed a certain character. We can check if an element is in a map using the syntax we saw earlier.

package main

import (
    "fmt"
    "os"
)

func main() {

    wordToGuess := "hello"
    guessesLeft := 5
    prevGuesses := make(map[string]bool)

    fmt.Println("Welcome to Hangman!")

    for guessesLeft > 0 {
        fmt.Printf("Make a guess (%d left): ", guessesLeft)
        var input string
        _, err := fmt.Scanln(&input)
        if err != nil {
            fmt.Printf("invalid user input; %s\n", err)
            os.Exit(1)
        }

        guessesLeft--

        if input == wordToGuess {
            fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
            return
        }

        if len(input) > 1 {
            fmt.Println("You did not guess the word correctly! Try again")
            continue
        }

        _, ok := prevGuesses[input]
        if ok {
            fmt.Printf("You already guessed %s! Try again\n", input)
            continue
        }

        prevGuesses[input] = true
    }

    fmt.Println("You ran out of guesses and lost!")
}

Let's also change the program to not punish the user for guessing duplicate characters. Let's only decrement the number of guesses left when they guess an incorrect word or guess a new character.

package main

import (
    "fmt"
    "os"
)

func main() {

    wordToGuess := "hello"
    guessesLeft := 5
    prevGuesses := make(map[string]bool)

    fmt.Println("Welcome to Hangman!")

    for guessesLeft > 0 {
        fmt.Printf("Make a guess (%d left): ", guessesLeft)
        var input string
        _, err := fmt.Scanln(&input)
        if err != nil {
            fmt.Printf("invalid user input; %s\n", err)
            os.Exit(1)
        }


        if input == wordToGuess {
            fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
            return
        }

        if len(input) > 1 {
            guessesLeft--
            fmt.Println("You did not guess the word correctly! Try again")
            continue
        }

        _, ok := prevGuesses[input]
        if ok {
            fmt.Printf("You already guessed %s! Try again\n", input)
            continue
        }

        guessesLeft--
        prevGuesses[input] = true
    }

    fmt.Println("You ran out of guesses and lost!")
}

Ok now let's check if the user has guessed the entire word yet. After they input a new character we should check if they've guessed every character. Let's make a new function to describe this functionality.

You can define this function right below our main function. Let's call it guessedEntireWord

func guessedEntireWord() {
}

Now we need to think about what inputs and outputs this function should have.

We know the output should be a bool. A true value would tell us that the entire word has been guessed and the game should end. false would mean that the word hasn't been guessed yet and that the game should continue.

The inputs should be the word we're trying to guess and the guesses we've previously made.

We can make our function look something like this:

func guessedEntireWord(word string, guesses map[string]bool) bool {
    return false
}

Inside this function we need to look at every letter of the word and see if we've guessed it before. If one of the letters of the word doesn't appear in our previous guesses, we know that we haven't guessed the entire word and can return false.

We can use the Go for loop to iterate through characters of a string (similar to how we iterated through elements of a slice with range over here)

func guessedEntireWord(word string, guesses map[string]bool) bool {
    for _, char := range word {
        _, ok := guesses[char]
        if !ok {
            return false
        }
    }
    return true
}

Ok so this code is iterating through every character in the word we're trying to guess. For each character, we check to see if we've guessed it before. If we haven't guessed the character before, we exit out of the function and return false. If all the characters of the word have been guessed, we then return true.

Let's call our newly made function inside our main program and print out a message accordingly.

package main

import (
    "fmt"
    "os"
)

func main() {

    wordToGuess := "hello"
    guessesLeft := 5
    prevGuesses := make(map[string]bool)

    fmt.Println("Welcome to Hangman!")

    for guessesLeft > 0 {
        fmt.Printf("Make a guess (%d left): ", guessesLeft)
        var input string
        _, err := fmt.Scanln(&input)
        if err != nil {
            fmt.Printf("invalid user input; %s\n", err)
            os.Exit(1)
        }


        if input == wordToGuess {
            fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
            return
        }

        if len(input) > 1 {
            guessesLeft--
            fmt.Println("You did not guess the word correctly! Try again")
            continue
        }

        _, ok := prevGuesses[input]
        if ok {
            fmt.Printf("You already guessed %s! Try again\n", input)
            continue
        }

        guessesLeft--
        prevGuesses[input] = true

        if guessedEntireWord(wordToGuess, prevGuesses) {
            fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
            return
        } 
    }

    fmt.Println("You ran out of guesses and lost!")
}

func guessedEntireWord(word string, guesses map[string]bool) bool {
    for _, char := range word {
        _, ok := guesses[char]
        if !ok {
            return false
        }
    }
    return true
}

If you try to compile your program with this code, you will notice that it won't compile. You should get a compile error like this.

./main.go:57:26: cannot use char (variable of type rune) as type string in map index

This error arises from the fact that when we loop through the characters of a string with range, we don't get a string type back. Go has another type that we didn't go over called rune. A rune is just a single character. Since we get back a rune from looping over the string, we can't use this rune as the key when looking up in a map of type map[string]bool. Our map expects keys to be of type string.

We have two options for how to handle this:

  1. Convert our rune to a string for the key lookup
  2. Change the type of our map to use a rune as it's key type

Here is the modification for option 1:

func guessedEntireWord(word string, guesses map[string]bool) bool {
    for _, char := range word {
        _, ok := guesses[string(char)]
        if !ok {
            return false
        }
    }
    return true
}

For the purposes of this tutorial, we'll only go over option 1. Option 2 is certainly something you could implement and it would make more sense since we only want to store single character guesses.

Let's also create another function which will tell us how many times the character guessed appears in the word. This is useful so we can decrement the number of guesses only when the user guesses a character incorrectly.

Given a word and a guessed character, we need to check how many times a character appears in a word. We can create a counter and increment it whenever we see an equivalent character.

func getNumOccurences(word string, guess string) int {
    numTimes := 0
    for _, char := range word {
        if string(char) == guess {
            numTimes++
        }
    }
    return numTimes
}

Let's add it to our program:

package main

import (
    "fmt"
    "os"
)

func main() {

    wordToGuess := "hello"
    guessesLeft := 5
    prevGuesses := make(map[string]bool)

    fmt.Println("Welcome to Hangman!")

    for guessesLeft > 0 {
        fmt.Printf("Make a guess (%d left): ", guessesLeft)
        var input string
        _, err := fmt.Scanln(&input)
        if err != nil {
            fmt.Printf("invalid user input; %s\n", err)
            os.Exit(1)
        }


        if input == wordToGuess {
            fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
            return
        }

        if len(input) > 1 {
            guessesLeft--
            fmt.Println("You did not guess the word correctly! Try again")
            continue
        }

        _, ok := prevGuesses[input]
        if ok {
            fmt.Printf("You already guessed %s! Try again\n", input)
            continue
        }

        prevGuesses[input] = true

        if guessedEntireWord(wordToGuess, prevGuesses) {
            fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
            return
        } 

        if getNumOccurences(wordToGuess, input) < 1 {
            guessesLeft--
            fmt.Printf("Incorrect. %s is not in the word\n", input)
        }
    }

    fmt.Println("You ran out of guesses and lost!")
}

func guessedEntireWord(word string, guesses map[string]bool) bool {
    for _, char := range word {
        _, ok := guesses[char]
        if !ok {
            return false
        }
    }
    return true
}

func getNumOccurences(word string, guess string) int {
    numTimes := 0
    for _, char := range word {
        if string(char) == guess {
            numTimes++
        }
    }
    return numTimes
}

Ok now we should have a working Hangman game. Try to type the letters of "hello" one by one.

Now, we can focus on making our game prettier and a few more quality of life features.

3.4 Printing out game state

Let's add a print out on every turn to tell the user which characters they've guessed already.

Let's say we're trying to guess hello and we've guessed "h" and "l". It should look something like this:

h _ l l _

Let's break down this problem. We want to print out a bunch of characters. The characters should be either _ or the actual letter of the word depending on if we've guessed it before.

This should look similar to our guessedEntireWord function. We can loop through each of the characters of the word to guess. If we've already guessed a character, we can print it out. If a character hasn't been guessed, print _ in it's place.

For this function, we won't print out a new character on every iteration but will collect them into a single string that gets printed out at the end.

func printGameState(word string, guesses map[string]bool) {
    gameState := ""
    for _, char := range word {
        _, ok := guesses[string(char)]
        if ok {
            gameState = gameState + string(char)
        } else {
            gameState = gameState + "_"
        }
        gameState = gameState + " "
    }
    fmt.Println(gameState)
}

If you remember, the + operator is defined on strings to do concatenation. So we concatenate our previous games state with our new character either _ or a character from the word. We then re-assign our gameState to reflect this updated value.

We also concatenate a space between every character for formatting reasons.

Ok now let's call this function at the end of every turn. Our program should look something like this:

package main

import (
    "fmt"
    "os"
)

func main() {

    wordToGuess := "hello"
    guessesLeft := 5
    prevGuesses := make(map[string]bool)

    fmt.Println("Welcome to Hangman!")

    for guessesLeft > 0 {
        fmt.Printf("Make a guess (%d left): ", guessesLeft)
        var input string
        _, err := fmt.Scanln(&input)
        if err != nil {
            fmt.Printf("invalid user input; %s\n", err)
            os.Exit(1)
        }


        if input == wordToGuess {
            fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
            return
        }

        if len(input) > 1 {
            guessesLeft--
            fmt.Println("You did not guess the word correctly! Try again")
            continue
        }

        _, ok := prevGuesses[input]
        if ok {
            fmt.Printf("You already guessed %s! Try again\n", input)
            continue
        }

        prevGuesses[input] = true

        if guessedEntireWord(wordToGuess, prevGuesses) {
            fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
            return
        } 

        if getNumOccurences(wordToGuess, input) < 1 {
            guessesLeft--
            fmt.Printf("Incorrect. %s is not in the word\n", input)
        }

        printGameState(wordToGuess, prevGuesses)
    }

    fmt.Println("You ran out of guesses and lost!")
}

func guessedEntireWord(word string, guesses map[string]bool) bool {
    for _, char := range word {
        _, ok := guesses[string(char)]
        if !ok {
            return false
        }
    }
    return true
}

func getNumOccurences(word string, guess string) int {
    numTimes := 0
    for _, char := range word {
        if string(char) == guess {
            numTimes++
        }
    }
    return numTimes
}

func printGameState(word string, guesses map[string]bool) {
    gameState := ""
    for _, char := range word {
        _, ok := guesses[string(char)]
        if ok {
            gameState = gameState + string(char)
        } else {
            gameState = gameState + "_"
        }
        gameState = gameState + " "
    }
    fmt.Println(gameState)
}

Try to play the game now. You should see the spots of the word getting filled in as you keep guessingcorrectly.

hangman

3.5 Randomize words

Ok let's make our game a bit more versatile. Instead of only guessing hello over and over again, let's have our program choose a random word from a word bank.

Let's define a word bank of multiple words as a slice.

words := []string{"hello", "suspicious", "tuas"}

Now let's choose one of these words at random. To select one of the words we can select a random index and use that index to access one of the slice elements. This means that we need a random integer between 0 and the number of words we have.

Here's the code for generating a random number from 0 to len(words). We won't touch on exactly how this works. Just know that our randomness depends on the seed. If our seed is the same between two calls to generate a random number, the numbers will be the same. This is why we set our seed to something that will change between each call (such as the current timestamp).

words := []string{"hello", "suspicious", "tuas"}

rand.Seed(time.Now().Unix())
wordToGuess := words[rand.Intn(len(words))]

There's a lot going on here. Let's break it down.

We setup as our seed to be the current unix timestamp, as we mentioned (note that we need to import the "time" package). We then get our random index (from 0 to len(words) exclusive). The random index is then used to index into the words slice and get a word string.

Let's substitute our hardcoded wordToGuess := "hello" with this new randomization:

package main

import (
    "fmt"
    "math/rand"
    "os"
    "time"
)

func main() {
    words := []string{"hello", "suspicious", "tuas"}

    rand.Seed(time.Now().Unix())
    wordToGuess := words[rand.Intn(len(words))]

    guessesLeft := 5
    prevGuesses := make(map[string]bool)

    fmt.Println("Welcome to Hangman!")

    for guessesLeft > 0 {
        fmt.Printf("Make a guess (%d left): ", guessesLeft)
        var input string
        _, err := fmt.Scanln(&input)
        if err != nil {
            fmt.Printf("invalid user input; %s\n", err)
            os.Exit(1)
        }


        if input == wordToGuess {
            fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
            return
        }

        if len(input) > 1 {
            fmt.Println("You did not guess the word correctly! Try again")
            continue
        }

        _, ok := prevGuesses[input]
        if ok {
            fmt.Printf("You already guessed %s! Try again\n", input)
            continue
        }

        prevGuesses[input] = true

        if guessedEntireWord(wordToGuess, prevGuesses) {
            fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
            return
        } 

        if getNumOccurences(wordToGuess, input) < 1 {
            guessesLeft--
            fmt.Printf("Incorrect. %s is not in the word\n", input)
        }

        printGameState(wordToGuess, prevGuesses)
    }

    fmt.Println("You ran out of guesses and lost!")
}

func guessedEntireWord(word string, guesses map[string]bool) bool {
    for _, char := range word {
        _, ok := guesses[string(char)]
        if !ok {
            return false
        }
    }
    return true
}

func printGameState(word string, guesses map[string]bool) {
    gameState := ""
    for _, char := range word {
        _, ok := guesses[string(char)]
        if ok {
            gameState = gameState + string(char)
        } else {
            gameState = gameState + "_"
        }
        gameState = gameState + " "
    }
    fmt.Println(gameState)
}

func getNumOccurences(word string, guess string) int {
    numTimes := 0
    for _, char := range word {
        if string(char) == guess {
            numTimes++
        }
    }
    return numTimes
}

There is one problem with this version of the program: we might have a word longer than our maximum number of guesses of 5. There are many ways we can handle this. For the purpose of this tutorial, I'll give the user one guess for each character in the word.

words := []string{"hello", "suspicious", "tuas"}

rand.Seed(time.Now().Unix())
wordToGuess := words[rand.Intn(len(words))]

guessesLeft := len(wordToGuess)
prevGuesses := make(map[string]bool)
...

With that, we have the final version of our game:

package main

import (
    "fmt"
    "math/rand"
    "os"
    "time"
)

func main() {
    words := []string{"hello", "suspicious", "tuas"}

    rand.Seed(time.Now().Unix())
    wordToGuess := words[rand.Intn(len(words))]

    guessesLeft := len(wordToGuess)
    prevGuesses := make(map[string]bool)

    fmt.Println("Welcome to Hangman!")

    for guessesLeft > 0 {
        fmt.Printf("Make a guess (%d left): ", guessesLeft)
        var input string
        _, err := fmt.Scanln(&input)
        if err != nil {
            fmt.Printf("invalid user input; %s\n", err)
            os.Exit(1)
        }


        if input == wordToGuess {
            fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
            return
        }

        if len(input) > 1 {
            fmt.Println("You did not guess the word correctly! Try again")
            continue
        }

        _, ok := prevGuesses[input]
        if ok {
            fmt.Printf("You already guessed %s! Try again\n", input)
            continue
        }

        prevGuesses[input] = true

        if guessedEntireWord(wordToGuess, prevGuesses) {
            fmt.Printf("You guessed the word %s correctly\n", wordToGuess)
            return
        } 

        if getNumOccurences(wordToGuess, input) < 1 {
            guessesLeft--
            fmt.Printf("Incorrect. %s is not in the word\n", input)
        }

        printGameState(wordToGuess, prevGuesses)
    }

    fmt.Println("You ran out of guesses and lost!")
}

func guessedEntireWord(word string, guesses map[string]bool) bool {
    for _, char := range word {
        _, ok := guesses[string(char)]
        if !ok {
            return false
        }
    }
    return true
}

func printGameState(word string, guesses map[string]bool) {
    gameState := ""
    for _, char := range word {
        _, ok := guesses[string(char)]
        if ok {
            gameState = gameState + string(char)
        } else {
            gameState = gameState + "_"
        }
        gameState = gameState + " "
    }
    fmt.Println(gameState)
}

func getNumOccurences(word string, guess string) int {
    numTimes := 0
    for _, char := range word {
        if string(char) == guess {
            numTimes++
        }
    }
    return numTimes
}

Of course, there are many ways you could improve this game. But this should be a good exercise for you to get a tour of Go types, control flow and idioms.

Resources