A program called antfarm. At the end of the course you will be able to run:
$ antfarm run --ants 50000 --grid 512x512 --food 400 \
--chaos crash=0.001,drop=0.02,slow=0.005 \
--metrics :9090 --view web:8080
colony: 50000 ants, 512x512 grid, 400 food sources
tick 1200 | alive 49863 | carrying 8214 | food delivered 31902
| restarts 137 | dropped msgs 4411 | p99 decide 412µs
while a browser tab shows the grid with pheromone trails intensifying along routes the colony has discovered, a Prometheus-style metrics endpoint exposes counters, and go tool pprof can attach to the running process and tell you where the CPU is going. In the final milestone the colony runs split across several operating-system processes that talk over TCP, and you can kill one of them and watch the rest continue.
Ant colonies are the canonical example of emergent behaviour: no ant knows where the food is, no ant is in charge, and yet the colony reliably finds short paths to food. The algorithm behind it is real (ant colony optimisation is a genuine technique for routing and scheduling problems), and it happens to be an almost perfect Go exercise, because the natural implementation is thousands of independent activities exchanging small messages.
It is also a trap, in a useful way. The obvious first implementation puts the world in a shared data structure and lets every ant touch it. That works until you add concurrency, at which point it corrupts itself in ways that only appear under load. Milestone 4 walks you into that bug deliberately, and Milestone 5 walks you out of it using the idiom Go actually recommends. That sequence is the single most valuable thing in this course.
go test -race and go run -race instrument memory accesses and report exactly which two goroutines touched the same address without synchronisation, with both stack traces. Very few languages hand you this for free.
Honestly: Erlang would be better at the fault-tolerance half of this project, and you will see why in Course 4. Erlang gives you supervision, isolated heaps, and true preemption; in Go, a panicking goroutine kills the whole process unless you catch it, and a goroutine stuck in a tight loop cannot be cancelled by force. In Milestone 8 you will hand-build a supervisor that Erlang would have given you.
What Go wins on is the combination: it is fast, statically typed, trivially deployable, has excellent profiling, and its concurrency is cheap enough for this scale while remaining familiar enough that you can be productive in a week. Python's asyncio could express the structure but would be roughly two orders of magnitude slower at 50,000 agents; Rust would be faster and safer but would spend your attention on the borrow checker instead of on concurrency design; Java's virtual threads (Project Loom) are now genuinely comparable and would be a fair alternative.
Worth separating, so you know what transfers:
| Transfers to other languages | Specific to Go |
|---|---|
| The idea that concurrency is a design problem about ownership | select with multiple channel cases and a default |
| Backpressure, bounded queues, load shedding | Channel closing semantics and the "close means done" convention |
| Supervision and restart strategies | context.Context as a cancellation value threaded through call chains |
| Profiling methodology | Implicit interface satisfaction and small interfaces |
| Chaos testing | defer/recover, and the culture of returning errors rather than throwing |
┌──────────────────────────┐
│ cmd/antfarm │ flags, config, wiring
└────────────┬─────────────┘
│
┌──────────────────────────────┼──────────────────────────────┐
│ │ │
┌───────▼────────┐ ┌────────▼─────────┐ ┌─────────▼────────┐
│ sim engine │ │ observability │ │ viewer │
│ │ │ │ │ │
│ world owner │◄─ queries ─┤ metrics, pprof, │ │ terminal / web │
│ goroutine │ │ event log │ │ (read-only) │
└───┬────────┬───┘ └──────────────────┘ └─────────▲────────┘
│ │ │
│ └───────────── snapshots (channel, buffered) ───────────┘
│
│ requests: Move, Sense, PickUp, Drop, Deposit
│ replies: per-request reply channel
│
┌───▼──────────────────────────────────────────────────────────┐
│ ant goroutines (N = 1..50,000) │
│ │
│ ant 1 ──┐ │
│ ant 2 ──┤ │
│ ... ├──► requests channel ──► world owner ──► replies │
│ ant N ──┘ │
└───┬──────────────────────────────────────────────────────────┘
│
┌───▼───────────┐ ┌────────────────┐ ┌──────────────────┐
│ supervisor │ │ pheromone │ │ chaos injector │
│ (restarts │ │ evaporator │ │ (crash, drop, │
│ dead ants) │ │ (ticker) │ │ delay) │
└───────────────┘ └────────────────┘ └──────────────────┘
Do not worry about understanding this yet. It is here so that when Milestone 5 introduces "the world owner goroutine" you can see where it fits. The important structural idea, which you will earn rather than be told, is that exactly one goroutine owns the world state, and everyone else asks it questions.
You will be able to explain, from having done it: why a mutex-per-cell design deadlocks and a single-owner design does not; what the race detector actually detects and what it misses; why an unbuffered channel send is a synchronisation point; how to cancel 50,000 goroutines in under a millisecond; how to tell an allocation problem from a contention problem in a profile; and what "backpressure" means in code rather than in a blog post.
Go is a compiled, statically typed language with a garbage collector. "Compiled" means a program is translated ahead of time into machine code for your CPU and operating system, producing a single executable file with no runtime to install alongside it. "Statically typed" means the type of every variable is known at compile time and mismatches are compile errors. "Garbage collected" means you do not free memory by hand.
Unusually, almost everything you need is one command, go. There is no separate package manager (no npm, no pip), no separate build system (no Make required), no separate formatter, no separate test runner. This is deliberate and it is one of the pleasant parts of the language.
| Task | Command | Equivalent elsewhere |
|---|---|---|
| run | go run ./cmd/antfarm | python main.py |
| build | go build ./... | make, cargo build |
| dependencies | go get, go mod tidy | pip install, npm install |
| test | go test ./... | pytest, jest |
| format | gofmt -w . | black, prettier |
| lint (built in) | go vet ./... | flake8, eslint |
| docs | go doc fmt.Println | help(), man |
| profile | go tool pprof | cProfile plus a viewer |
You want Go 1.22 or newer for this course, because we use a few features introduced there (ranging over integers, and the corrected loop-variable semantics). At the time of writing the current release is in the 1.25/1.26 range; my knowledge of releases stops in mid-2026, so check go.dev/dl for what is current and prefer the newest stable version.
# Option A: Homebrew
brew install go
# Option B: official package
# download the .pkg from https://go.dev/dl/ and double-click it
go version # should print something like: go version go1.25.1 darwin/arm64
Distribution packages are often a year or more out of date, so install from the tarball:
# adjust the version and architecture to match what go.dev/dl offers
curl -LO https://go.dev/dl/go1.25.1.linux-amd64.tar.gz
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go1.25.1.linux-amd64.tar.gz
# add to ~/.bashrc or ~/.zshrc, then restart the shell
export PATH=$PATH:/usr/local/go/bin
export PATH=$PATH:$(go env GOPATH)/bin
go version
The rm -rf /usr/local/go matters: the official instructions require removing the old installation rather than untarring over it, because leftover files from a previous version cause confusing errors.
:: Option A (PowerShell or cmd)
winget install --id GoLang.Go
:: Option B: download the .msi from https://go.dev/dl/ and run it
go version
The installer edits PATH for you, but you must open a new terminal window afterwards for that to take effect. If you plan to do the Perl and Erlang courses too, consider installing WSL2 (wsl --install in an administrator PowerShell) and doing everything inside Linux instead; the Go experience is good either way, but the other two courses are noticeably smoother on Linux.
go version # the compiler version
go env GOPATH # where downloaded modules and installed binaries live
go env GOROOT # where Go itself is installed (you rarely touch this)
GOPATH defaults to ~/go on macOS and Linux and %USERPROFILE%\go on Windows. If you read an old tutorial saying your code must live inside GOPATH: that has not been true since 2019. With modules, your code lives wherever you like, and GOPATH is just a cache plus a bin directory.
mkdir antfarm
cd antfarm
go mod init github.com/yourname/antfarm
That last command creates a file called go.mod:
module github.com/yourname/antfarm
go 1.25
Three things to understand here.
A module is a unit of versioning and dependency. It is roughly "one repository". The go.mod file records the module's own name, the minimum Go version, and every dependency with its exact version.
The module path is an identity, not a download instruction. github.com/yourname/antfarm is how other code refers to your packages. It looks like a URL because if you ever publish it, Go will fetch it from there. You do not need a GitHub repository to use this name, and nothing contacts the network because of it. Use your real username if you plan to publish; otherwise example.com/antfarm is fine.
There is no lockfile step. When you add a dependency, Go writes it to go.mod and records cryptographic hashes in go.sum. Both are committed to git.
Go has no enforced project layout, but there is a strong convention that you should follow because every Go programmer reads it instantly:
antfarm/
├── go.mod module definition
├── go.sum dependency hashes (appears when you add deps)
├── cmd/
│ └── antfarm/
│ └── main.go the executable: flags, wiring, nothing clever
├── internal/ importable only by this module
│ ├── sim/ the simulation engine
│ ├── world/ the grid, food, pheromones
│ └── metrics/
├── pkg/ (optional) code you intend others to import
└── README.md
Two rules worth knowing now:
.go files in one directory belong to the same package and can see each other's definitions without imports. Subdirectories are separate packages. internal/ is enforced by the compiler. Any package under a directory named internal can only be imported by code rooted in the parent of that internal directory. This is not a convention, it is a compile error. It lets you have public-looking structure without committing to a public API. Use gopls, the official Go language server. It gives completion, jump-to-definition, inline errors, and automatic import management.
.go file it offers to install tools; accept. Then enable format-on-save, which will also fix your imports as you type.go install golang.org/x/tools/gopls@latest, then configure gopls in your LSP setup. gopls the same way and point your editor at it.Turn on format-on-save now rather than later. Go has exactly one formatting style, produced by gofmt, and nobody argues about it. Indentation is tabs, which surprises people coming from Python or JavaScript. Let the tool do it.
Create cmd/antfarm/main.go:
package main
import "fmt"
func main() {
fmt.Println("Hello, colony.")
}
Run it:
$ go run ./cmd/antfarm
Hello, colony.
package main — every Go file starts by declaring which package it belongs to. The name main is special: a package named main that contains a function named main is compiled into an executable program. Every other package name produces a library. Note that the package name here is unrelated to the directory name antfarm; the directory determines the import path, the package line determines the name. For main packages the directory name becomes the binary's name.
import "fmt" — brings in the standard library package fmt (short for "format"), which handles printing and string formatting. The quoted string is an import path. For standard library packages it is just the name; for external ones it is the full module path, for example "github.com/spf13/cobra".
func main() { — declares a function called main taking no parameters and returning nothing. Go requires the opening brace on the same line as the declaration. This is not a style preference; the compiler inserts semicolons at line ends, so a brace on the next line produces a syntax error. In a main package, this function is where execution starts, and when it returns the program exits.
fmt.Println("Hello, colony.") — calls the Println function from the fmt package. The capital P is load-bearing: in Go, an identifier that starts with an uppercase letter is exported (visible to other packages), and lowercase means package-private. There is no public or private keyword. Println prints its arguments and appends a newline.
Start to start changes its visibility.# compile and run in one step (binary goes to a temp dir)
go run ./cmd/antfarm
# compile to a named file
go build -o bin/antfarm ./cmd/antfarm
./bin/antfarm
# compile and install into $(go env GOPATH)/bin, so it is on your PATH
go install ./cmd/antfarm
# format every file in the tree
gofmt -w .
# the built-in correctness checker: finds real bugs, not style nits
go vet ./...
# run all tests in all packages
go test ./...
# read documentation without a browser
go doc fmt.Println
go doc -all strings | less
The ./... pattern means "this directory and every package beneath it". You will type it constantly.
For documentation, pkg.go.dev hosts rendered docs for the standard library and for every public module. go doc gives you the same content offline. go help and go help mod explain the toolchain itself.
Modify the program so that it prints three lines: the Go version it was built with, the name of the operating system, and the number of CPU cores available. Then build it as a binary named antfarm in a bin/ directory and run that binary.
Hints: the runtime package has Version(), GOOS, and NumCPU(). Use go doc runtime to explore. Note that GOOS is a constant, not a function.
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println("go version:", runtime.Version())
fmt.Println("os: ", runtime.GOOS)
fmt.Println("cpus: ", runtime.NumCPU())
}
$ go build -o bin/antfarm ./cmd/antfarm
$ ./bin/antfarm
go version: go1.25.1
os: darwin
cpus: 10
The grouped import ( ... ) form is what gofmt produces once you have more than one import. runtime.NumCPU() is the number of cores visible to the process, which becomes relevant in Milestone 11 when we talk about parallelism versus concurrency.

go: cannot find main module — you are not inside a directory containing go.mod, or below one. Run go mod init.imported and not used: "runtime" — remove the import, or use it. Not a warning.declared and not used: x — same idea for local variables. Assigning to _ silences it deliberately: _ = x.syntax error: unexpected newline, expecting { after ... — you put the opening brace on its own line.package antfarm is not a main package — you wrote package antfarm in a file you are trying to run. Executables must say package main.go: command not found after installing — your PATH does not include Go's bin directory, or you did not restart the terminal.github.com/yourname/antfarm come from and what is it used for?yourmodule/internal/sim?go build, go run, and go install? This part teaches only what the ant colony needs. Go is a small language and this is most of it, but I am deliberately skipping generics (until Milestone 11), reflection, struct tags, and embedding beyond one mention. Each concept follows the same rhythm: the idea, a small example, a line-by-line reading, and the mistakes people make. Exercises appear after every group of related concepts, with the solution folded away underneath.
Work through this with a scratch project open. Make a directory scratch, run go mod init scratch, and put experiments in main.go. Type the examples rather than reading them.
Every variable has a type fixed at compile time. You can write the type explicitly, or let Go infer it from the initial value. A variable declared without a value is not undefined or null; it holds its type's zero value, which is a real, usable value.
package main
import "fmt"
func main() {
var ticks int = 10 // explicit type, explicit value
var grid = "512x512" // type inferred: string
var alive bool // no value: zero value, which is false
name := "worker-7" // short declaration, only inside functions
const maxAnts = 50000 // compile-time constant
fmt.Println(ticks, grid, alive, name, maxAnts)
}
var ticks int = 10 — the full form. Reading order is name, then type, then value, which is backwards from C and Java and matches the way you say it out loud ("ticks is an int").var grid = "512x512" — omit the type and it is inferred.var alive bool — omit the value and you get the zero value. For bool that is false; for numbers 0; for strings ""; for pointers, slices, maps, channels, functions and interfaces it is nil. There is no uninitialised memory in Go and no undefined. name := "worker-7" — the short declaration operator. It declares and assigns in one step, inferring the type. It only works inside a function, and it requires at least one variable on the left to be new.const maxAnts = 50000 — constants are computed at compile time and have no address. An untyped constant like this adapts to the context it is used in, so maxAnts can be used where an int, int64, or float64 is wanted.In Python or JavaScript a "not yet set" variable is a distinct state you must check for (None, undefined). In Go the zero value is designed to be useful: a zero sync.Mutex is an unlocked mutex, a zero bytes.Buffer is an empty buffer ready to write to, a nil slice behaves like an empty slice for len, range, and append. When you design your own structs, you should aim for the same property. Our Ant struct will be designed so that its zero value is a valid, if boring, ant.
:= at package level. It does not work there; use var.no new variables on left side of := — you used := when every variable already exists. Use =.if block, err := f() creates a new err that disappears at the closing brace, leaving the outer one untouched. This is the single most common Go bug. go vet catches some cases; the shadow analyser catches more. var a int = 1; var b int64 = a is an error; you must write int64(a).Write a function zeroReport() string that declares four local variables — int, string, bool, and []int — without assigning any of them a value, and returns one formatted string showing all four zero values. Then explain why the slice's zero value prints as [] rather than crashing the program the way an uninitialised pointer would in C.
func zeroReport() string {
var n int
var s string
var b bool
var xs []int
return fmt.Sprintf("%d %q %v %v", n, s, b, xs)
}
// "0 \"\" false []"
A nil slice is a valid, usable value: len(xs) is 0, ranging over it does nothing, and %v prints it as [], exactly like an empty slice. There is no separate "uninitialised" state to crash on, because a slice's zero value is a real three-word struct (pointer, length, capacity) with the pointer set to nil and the other two fields 0 — reading it is always safe, only writing through it would panic, and xs here is never written to.
Functions can return more than one value, and this is how Go handles errors: a function returns its result and an error side by side. defer schedules a call to run when the surrounding function returns, no matter how it returns.
func divide(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("divide %d by zero", a)
}
return a / b, nil
}
func report(name string) {
defer fmt.Println("done:", name) // runs last, whatever happens
fmt.Println("start:", name)
if name == "" {
return // the deferred call still runs
}
fmt.Println("working:", name)
}
func divide(a, b int) (int, error) — two parameters of the same type share one type annotation. The return types are in parentheses because there are two of them.fmt.Errorf(...) — builds an error value with a formatted message. %d substitutes an integer, exactly like printf.return 0, nil — you must return a value for every declared return, so the "unused" one gets a zero value. The convention is that when error is non-nil, the other results are meaningless.defer fmt.Println(...) — the arguments are evaluated now but the call happens when report returns. Deferred calls run in last-in-first-out order. This is how Go does cleanup: defer file.Close(), defer mu.Unlock(), defer cancel(). Close explicitly.defer fmt.Println(i) in a loop prints the value of i at the moment of the defer, not at return time. Wrap in a closure if you want late evaluation: defer func(){ fmt.Println(i) }().value, _ := divide(1, 0) compiles happily and gives you nonsense. errcheck and most linters flag this.Write firstAndRest(xs []int) (first int, rest []int, ok bool) that reports the first element and a slice of the remaining ones, with ok false and the other two results left at their zero values for an empty slice. Then write logged(name string, f func()) that prints "start: "+name, calls f, and is guaranteed to print "done: "+name afterwards — using defer, not a plain call written after f().
func firstAndRest(xs []int) (first int, rest []int, ok bool) {
if len(xs) == 0 {
return 0, nil, false
}
return xs[0], xs[1:], true
}
func logged(name string, f func()) {
defer fmt.Println("done:", name)
fmt.Println("start:", name)
f()
}
Writing fmt.Println("done:", name) as a plain statement after f() looks identical when f behaves. It stops being identical the moment f panics: a plain statement after f() never runs, because the panic skips straight past it, while the deferred call still fires during the unwind. "Runs no matter how the function returns" includes the paths you did not write a test for.
Go has one loop keyword, for, which covers every loop shape. if can declare a variable scoped to the statement. switch does not fall through by default.
// classic three-part loop
for i := 0; i < 5; i++ {
fmt.Println(i)
}
// while loop
n := 10
for n > 0 {
n--
}
// infinite loop
for {
break
}
// range over a slice: index and value
ants := []string{"a1", "a2", "a3"}
for i, name := range ants {
fmt.Println(i, name)
}
// range over an integer (Go 1.22+)
for i := range 3 {
fmt.Println(i) // 0 1 2
}
// if with an initialiser: err exists only inside the if/else
if q, err := divide(10, 2); err != nil {
fmt.Println("failed:", err)
} else {
fmt.Println("got", q)
}
// switch on a value, no break needed
switch state {
case "searching":
fmt.Println("looking for food")
case "carrying", "returning": // multiple values in one case
fmt.Println("heading home")
default:
fmt.Println("idle")
}
// switch with no expression: a tidy if/else chain
switch {
case energy < 10:
fmt.Println("starving")
case energy < 50:
fmt.Println("hungry")
default:
fmt.Println("fine")
}
for i, name := range ants — range yields index and element for slices and arrays, key and value for maps, and values for channels. If you only want the index, write for i := range ants. If you only want the value, write for _, name := range ants, where _ is the blank identifier meaning "discard this". if q, err := ...; err != nil — the initialiser before the semicolon runs first, and any variables it declares are scoped to the whole if/else chain. This is the idiomatic way to handle errors without leaking names.switch cases do not fall through. If you actually want fallthrough, there is a fallthrough keyword, which is rare. while. It does not exist.range over a map has a stable order. It is deliberately randomised, to stop you depending on it. Collect and sort the keys if order matters.for _, a := range ants { a.Energy = 0 } mutates a copy when the element is a struct value. Use for i := range ants { ants[i].Energy = 0 }. Write a function summary(values []int) (min int, max int, err error) that returns the smallest and largest values in a slice, and an error if the slice is empty. Then write a main that calls it twice, once with data and once with an empty slice, printing both outcomes. Do not use any library beyond fmt and errors.
package main
import (
"errors"
"fmt"
)
var ErrEmpty = errors.New("summary: empty slice")
func summary(values []int) (min int, max int, err error) {
if len(values) == 0 {
return 0, 0, ErrEmpty
}
min, max = values[0], values[0]
for _, v := range values[1:] {
if v < min {
min = v
}
if v > max {
max = v
}
}
return min, max, nil
}
func main() {
if lo, hi, err := summary([]int{4, 9, 1, 7}); err != nil {
fmt.Println("error:", err)
} else {
fmt.Println("min", lo, "max", hi)
}
if _, _, err := summary(nil); err != nil {
fmt.Println("error:", err) // error: summary: empty slice
}
}
Three things worth noticing. The return values are named in the signature, which documents them and lets you write a bare return; use this sparingly, since bare returns in long functions are hard to read. values[1:] is a slice expression meaning "from index 1 to the end". And summary(nil) works because a nil slice has length zero, which is the zero-value-is-useful principle in action.
A slice is Go's growable list. Under the hood it is a three-word value: a pointer to an underlying array, a length, and a capacity. Understanding that is not optional, because it explains the behaviour that surprises everyone.
positions := []int{10, 20, 30} // literal, len 3, cap 3
positions = append(positions, 40) // append returns a NEW slice value
fmt.Println(len(positions), cap(positions))
grid := make([]int, 0, 1000) // len 0, capacity 1000: no reallocation
// for the first 1000 appends
view := positions[1:3] // len 2, shares memory with positions
view[0] = 99 // this modifies positions[1] too!
fmt.Println(positions) // [10 99 30 40]
[]int{10, 20, 30} — a slice literal. []int is the type "slice of int". ([3]int, with a number, is a fixed-size array, which is a different and much less used type.)append(positions, 40) — appends and returns the result. If the underlying array has spare capacity it writes in place; if not, it allocates a bigger array and copies. Because it might reallocate, you must use the return value. append(positions, 40) on its own line is a bug that go vet will not always catch.make([]int, 0, 1000) — make creates slices, maps, and channels. The arguments are type, length, capacity. Preallocating capacity when you know the size is the single easiest Go performance win, and we will use it for the ant list.positions[1:3] — a view, not a copy. It shares the same backing array. Writing through the view is visible through the original. To copy, use copy(dst, src) or slices.Clone. Python's list[1:3] copies. Go's slice[1:3] aliases. That aliasing is the point: it makes subslicing free, which matters when you are scanning large buffers. It also means that passing a slice to a function lets that function modify your elements, even though the slice header itself is passed by value. "Slices are references" is the common shorthand, but the precise version is "slices are values containing a pointer", and the distinction shows up when a function appends to a slice you passed in.
append's result. Always s = append(s, x).slices.Clone breaks the link.len and cap. len is how many elements exist; cap is how many fit before reallocation. A hash table with typed keys and values. Reading a missing key gives the zero value rather than an error, and there is a special two-value form to tell "missing" from "present but zero".
pheromone := make(map[string]float64)
pheromone["12,7"] = 0.8
v := pheromone["99,99"] // 0, no error, no panic
v, ok := pheromone["99,99"] // v = 0, ok = false
if strength, ok := pheromone["12,7"]; ok {
fmt.Println("found", strength)
}
delete(pheromone, "12,7")
fmt.Println(len(pheromone))
for cell, strength := range pheromone { // order is randomised on purpose
fmt.Println(cell, strength)
}
make(map[K]V) creates an empty, usable map. A map declared with var m map[string]int is nil: reading from it is fine and returns zero values, but writing to it panics. This asymmetry catches everyone once. value, ok := m[key] form is called the "comma ok" idiom and appears again with type assertions and channel receives.fatal error: concurrent map writes. That crash is a feature, and you will meet it.Write countByFirstLetter(words []string) map[byte]int that counts how many words start with each byte, skipping empty strings. Then print the result in a stable order — maps do not range in a stable order, so collect the keys into a slice and sort it first.
func countByFirstLetter(words []string) map[byte]int {
counts := make(map[byte]int)
for _, w := range words {
if len(w) == 0 {
continue
}
counts[w[0]]++
}
return counts
}
func printSorted(counts map[byte]int) {
keys := make([]byte, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
for _, k := range keys {
fmt.Printf("%c: %d\n", k, counts[k])
}
}
counts[w[0]]++ works with no prior check, because reading a missing key returns the zero value and ++ then writes 1. Sorting the keys before printing is not paranoia: for k := range counts is deliberately randomised by the runtime specifically so that code cannot come to depend on an order that was never promised.
A struct is a fixed collection of named fields. A pointer holds the address of a value. A method is a function with a receiver, which is the value it is attached to. Go has no classes and no inheritance.
type Position struct {
X, Y int
}
type Ant struct {
ID int
Pos Position
Energy int
Carrying bool
}
// value receiver: operates on a copy
func (a Ant) Describe() string {
return fmt.Sprintf("ant %d at (%d,%d)", a.ID, a.Pos.X, a.Pos.Y)
}
// pointer receiver: can modify the original
func (a *Ant) Move(dx, dy int) {
a.Pos.X += dx
a.Pos.Y += dy
a.Energy--
}
func main() {
a := Ant{ID: 1, Pos: Position{X: 5, Y: 5}, Energy: 100}
a.Move(1, 0) // Go automatically takes &a
fmt.Println(a.Describe()) // ant 1 at (6,5)
p := &a // p is a *Ant
p.Move(0, 1) // no need to write (*p).Move
fmt.Println(a.Pos) // {6 6} — the original changed
}
type Ant struct { ... } — declares a new named type. Fields with uppercase names are visible outside the package.Ant{ID: 1, Pos: ...} — a composite literal with field names. Always use field names; the positional form breaks when someone adds a field.func (a Ant) Describe() string — the part in parentheses before the name is the receiver. Here it is a value, so a is a copy and modifications would be discarded. func (a *Ant) Move(...) — a pointer receiver. a points at the original, so the mutation sticks. Note that you write a.Pos.X, not (*a).Pos.X; Go dereferences pointers automatically for field access and method calls.a.Move(1, 0) where a is a value and Move needs a pointer — Go inserts &a for you, because a is addressable. This convenience has one important exception: values stored in a map are not addressable, so ants["x"].Move(1,0) will not compile. Store pointers in the map instead.The rule for choosing a receiver: use a pointer receiver if the method modifies the receiver, or if the struct is large enough that copying it matters, or if any other method on the type needs a pointer. Mixing value and pointer receivers on one type is a smell. For Ant, which we will mutate constantly, everything will be a pointer receiver.
There is no class, no constructor, no inheritance, no this. The conventional replacement for a constructor is a plain function named New or NewAnt that returns a value or pointer. The replacement for inheritance is embedding (putting one struct inside another without a field name, which promotes its methods) plus interfaces. Go's designers left inheritance out on purpose, and after a week you stop reaching for it.
An interface is a set of method signatures. A type satisfies an interface simply by having those methods. There is no implements keyword and no declaration of intent, which means you can define an interface after the fact, in the package that consumes it.
type Behaviour interface {
Decide(a *Ant, w *World) Action
Name() string
}
type Forager struct{}
func (Forager) Name() string { return "forager" }
func (Forager) Decide(a *Ant, w *World) Action {
if a.Carrying {
return Action{Kind: ReturnHome}
}
return Action{Kind: SearchFood}
}
// anything with Decide and Name can be passed here
func step(b Behaviour, a *Ant, w *World) {
act := b.Decide(a, w)
w.Apply(a, act)
}
type Behaviour interface { ... } — just method signatures. Forager never mentions Behaviour, yet satisfies it.func (Forager) Name() string — the receiver has no name because the method does not use it. Legal and common for stateless implementations.any (an alias for interface{}) is the empty interface, satisfied by everything. Use it rarely.f, ok := b.(Forager) is a type assertion, and a switch v := x.(type) is a type switch. In Java or C# an interface is a contract the implementer signs up to in advance, so the library author must anticipate your needs. In Go, the consumer defines the interface it needs, and existing types satisfy it retroactively. The cultural consequence is that idiomatic Go interfaces are tiny, often one method (io.Reader, io.Writer, error), and the proverb is "accept interfaces, return structs". We will follow that: our simulation accepts a Behaviour, and our constructors return concrete types.
Define a Grid struct holding width, height, and a one-dimensional []int of cells. Give it a constructor NewGrid(w, h int) *Grid, an At(x, y int) int method, and a Set(x, y, v int) method. Cells outside the grid should be treated as 0 for At and ignored for Set rather than panicking. Explain to yourself why the cells are a flat slice instead of a slice of slices.
type Grid struct {
W, H int
cells []int
}
func NewGrid(w, h int) *Grid {
return &Grid{W: w, H: h, cells: make([]int, w*h)}
}
func (g *Grid) inBounds(x, y int) bool {
return x >= 0 && y >= 0 && x < g.W && y < g.H
}
func (g *Grid) At(x, y int) int {
if !g.inBounds(x, y) {
return 0
}
return g.cells[y*g.W+x]
}
func (g *Grid) Set(x, y, v int) {
if !g.inBounds(x, y) {
return
}
g.cells[y*g.W+x] = v
}
Why flat: one allocation instead of h+1, contiguous memory so scanning a row is cache-friendly, and a single copy can snapshot the whole grid, which we will need when the viewer asks for a frame. cells is lowercase so that nothing outside the package can index it without bounds checking. inBounds is lowercase for the same reason: it is an internal helper.
An error is a value implementing one method. You return it, you check it, you wrap it with context as it travels up. Go has panics too, but they are for programmer mistakes and truly unrecoverable situations, not for control flow.
// the entire definition, from the standard library:
// type error interface { Error() string }
var ErrNoFood = errors.New("no food available") // a sentinel error
func (w *World) TakeFood(p Position) (int, error) {
amount := w.food.At(p.X, p.Y)
if amount == 0 {
return 0, fmt.Errorf("take at (%d,%d): %w", p.X, p.Y, ErrNoFood)
}
w.food.Set(p.X, p.Y, amount-1)
return 1, nil
}
func forage(w *World, p Position) {
got, err := w.TakeFood(p)
if errors.Is(err, ErrNoFood) { // unwraps through %w
// expected: move elsewhere
return
}
if err != nil {
log.Printf("unexpected: %v", err)
return
}
_ = got
}
// a custom error type, when callers need structured detail
type OutOfBounds struct{ X, Y int }
func (e *OutOfBounds) Error() string {
return fmt.Sprintf("position (%d,%d) is outside the grid", e.X, e.Y)
}
// retrieving it:
var oob *OutOfBounds
if errors.As(err, &oob) {
fmt.Println("bad x was", oob.X)
}
errors.New creates a simple error. A package-level Err... variable is called a sentinel, and callers compare against it.%w in fmt.Errorf wraps: the new error contains the old one, so context accumulates while identity is preserved. Use %v instead if you deliberately want to hide the cause.errors.Is(err, target) walks the wrap chain comparing identity. errors.As(err, &target) walks it looking for a specific type. Never compare error strings. panic unwinds the stack running deferred functions and then crashes the program, printing a stack trace. recover, called inside a deferred function, stops the unwinding. We will use exactly one recover in the whole project, in Milestone 8, at the top of each ant goroutine, and I will argue about whether that is a good idea.Exceptions are invisible in a function signature and travel silently through every frame. Go's errors are ordinary values, so the signature tells you what can fail, and the if err != nil blocks are explicit. The cost is verbosity, and it is real: around a third of the lines in a typical Go program are error handling. The benefit is that for a long-running simulation you can see every failure path in the code rather than discovering it in production. This is a genuine trade-off, not a clear win, and Rust's ? operator arguably gets a better deal.
Put go in front of a function call and it runs concurrently. A goroutine is not an operating-system thread; it is a much cheaper thing that the Go runtime multiplexes onto a small pool of threads.
func main() {
go fmt.Println("from a goroutine") // may never print!
fmt.Println("from main")
time.Sleep(10 * time.Millisecond) // crude, but proves the point
}
Two facts to absorb immediately:
main returns, the program exits, killing every goroutine still running, with no cleanup and no warning. Half of all beginner "my goroutine didn't run" reports are this.time.Sleep is never the answer. The real tool for "wait for a group of goroutines" is sync.WaitGroup:
var wg sync.WaitGroup
for i := range 5 {
wg.Add(1) // register one pending goroutine, BEFORE go
go func() {
defer wg.Done() // signal completion, even on panic
fmt.Println("ant", i, "reporting")
}()
}
wg.Wait() // blocks until the counter reaches zero
fmt.Println("all ants reported")
wg.Add(1) must happen before the go statement. If you put it inside the goroutine, Wait can return before the goroutine has even started.defer wg.Done() as the first line of the goroutine guarantees the counter is decremented on every exit path.i. Since Go 1.22 each loop iteration gets its own i, so this prints 0 through 4 in some order. In Go 1.21 and earlier the loop variable was shared and this printed "5" five times, which was the most notorious Go gotcha. Older tutorials pass i as an argument to work around it; that is still correct, just no longer necessary.wg.Go(func(){ ... }), which does the Add and Done for you. If your compiler rejects it, your version is older; the classic form above always works. Traditional threads Goroutines
─────────────────── ──────────
~1-8 MB stack reserved each ~2 KB stack, grows on demand
created by the OS, expensive created by the runtime, ~1µs
scheduled by the OS kernel scheduled by the Go runtime onto
GOMAXPROCS OS threads
blocking syscall blocks a thread runtime moves other goroutines to
another thread automatically
10,000 is a lot 1,000,000 is fine
you coordinate with locks you coordinate with channels
(locks also available)This is why "one goroutine per ant" is a sane architecture and "one thread per ant" is not. It is also why Go can afford a blocking programming style: a goroutine waiting on a channel or a network read costs almost nothing, so you write straight-line code instead of callbacks or async/await colouring.
Launch 10 goroutines that each increment a shared atomic.Int64 1,000 times, wait for all of them with a WaitGroup, and print the final total. Run it several times and confirm it is exactly 10,000 every time. Then explain why a plain var counter int with counter++ in the same loop would not reliably give you 10,000.
var counter atomic.Int64
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
counter.Add(1)
}
}()
}
wg.Wait()
fmt.Println(counter.Load()) // always 10000
counter.Add(1) is a single indivisible hardware operation, so 10,000 of them from however many goroutines always land exactly once each. counter++ on a plain int is not one operation; it is read, increment, write, and two goroutines can both read the same value before either writes back, so one increment is silently lost. The bug does not show up every run — it depends on the scheduler interleaving two increments at exactly the wrong moment — which is what makes unsynchronised counters so easy to ship and so hard to diagnose from a bug report that says "the number is sometimes wrong".
A channel is a typed pipe that also synchronises. One goroutine sends, another receives, and the channel handles the handover safely. The slogan is: do not communicate by sharing memory; share memory by communicating.
ch := make(chan int) // unbuffered
buf := make(chan int, 100) // buffered, holds 100 before blocking
go func() {
ch <- 42 // send: blocks until someone receives
}()
v := <-ch // receive: blocks until someone sends
fmt.Println(v) // 42
close(ch) // no more values will be sent
v, ok := <-ch // ok is false once drained and closed
// range over a channel until it is closed
results := make(chan string, 3)
go func() {
defer close(results) // the sender closes, always
results <- "found food"
results <- "laid pheromone"
}()
for msg := range results {
fmt.Println(msg)
}
| Operation | nil channel | open, empty/full | closed |
|---|---|---|---|
| send | blocks forever | blocks until space | panics |
| receive | blocks forever | blocks until value | returns zero value immediately, ok false |
| close | panics | succeeds | panics |
context works internally.WaitGroup. func consume(in <-chan int) accepts a receive-only channel and func produce(out chan<- int) a send-only one. The arrow points the way data flows. Use these in signatures; they document intent and the compiler enforces it.Write sum(nums []int) int that splits nums into chunks of 4, launches one goroutine per chunk to add up its slice and send the partial sum on a shared buffered channel, then receives exactly as many values as there are chunks and adds them up. Do not use a WaitGroup. Explain why counting channel receives is enough to know you are done.
func sum(nums []int) int {
const chunkSize = 4
partial := make(chan int, (len(nums)+chunkSize-1)/chunkSize)
chunks := 0
for i := 0; i < len(nums); i += chunkSize {
end := min(i+chunkSize, len(nums))
chunks++
go func(chunk []int) {
s := 0
for _, v := range chunk {
s += v
}
partial <- s
}(nums[i:end])
}
total := 0
for i := 0; i < chunks; i++ {
total += <-partial
}
return total
}
A WaitGroup answers "have all the goroutines finished?"; here the question is narrower — "have I received one value from each of them?" — and a channel already answers that on its own: each goroutine sends exactly one value, so receiving chunks times is receiving every value that will ever be sent, no more bookkeeping required. A WaitGroup earns its place when goroutines do work you care about after their last send, or send zero or multiple values; when "one value per goroutine" is the whole contract, counting receives is simpler and just as correct.
select waits on several channel operations at once and proceeds with whichever is ready. It is the control structure that makes channels composable.
func worker(jobs <-chan int, done <-chan struct{}) {
timeout := time.After(5 * time.Second)
for {
select {
case job, ok := <-jobs:
if !ok {
fmt.Println("jobs channel closed")
return
}
fmt.Println("working on", job)
case <-done:
fmt.Println("asked to stop")
return
case <-timeout:
fmt.Println("took too long")
return
}
}
}
// non-blocking variants
select {
case v := <-ch:
fmt.Println("got", v)
default:
fmt.Println("nothing waiting") // never blocks
}
select {
case out <- value:
// sent
default:
// receiver is busy: drop the value rather than block.
// This is load shedding, and it is a real strategy.
}
select picks one at random, which prevents starvation.default, select blocks until some case is ready. With a default, it never blocks. chan struct{} is the idiomatic "signal only, no data" channel; an empty struct occupies zero bytes.time.After(d) returns a channel that delivers a value after d. Convenient, but it allocates a timer each time through a hot loop, so in performance-sensitive loops use time.NewTimer and reset it. nil channel in a select case blocks forever, so setting a channel variable to nil is how you dynamically disable a case. This trick appears in Milestone 11.Write firstOf(a, b <-chan int) int that returns whichever of two channels produces a value first. Use select with no default. Then explain what would go wrong if you added a default case to that select.
func firstOf(a, b <-chan int) int {
select {
case v := <-a:
return v
case v := <-b:
return v
}
}
With no default, select blocks until a or b has a value, which is exactly "whichever comes first". Adding default turns this into a non-blocking poll: if neither channel happens to be ready on this exact pass, the default case runs immediately and firstOf has nothing to return, breaking the function's contract entirely. default is for "check right now and move on", not for "wait for the first of several things", and mixing the two up is a common source of code that seems to work in testing and then returns garbage the moment either channel is even a microsecond slower to produce a value.
Channels are not always the right answer. When several goroutines genuinely need to read and write one piece of shared state, a mutex is simpler and faster. Go provides both and expects you to choose.
type Counters struct {
mu sync.Mutex
delivered int
byAnt map[int]int
}
func (c *Counters) Record(antID int) {
c.mu.Lock()
defer c.mu.Unlock()
c.delivered++
c.byAnt[antID]++
}
// for a single number, an atomic is cheaper than a mutex
var ticks atomic.Int64
func tick() {
ticks.Add(1)
}
func report() int64 {
return ticks.Load()
}
sync.Mutex is an unlocked mutex, so no initialisation is needed. Put it next to the data it protects, and add a comment saying what it protects.defer c.mu.Unlock() immediately after Lock is the safe habit. It costs a few nanoseconds and eliminates a class of bug.go vet catches this and it is a genuine bug: the copy has its own independent lock. This is why methods on mutex-containing types take pointer receivers.sync.RWMutex allows many concurrent readers or one writer. It is slower than Mutex under write-heavy load, so measure before assuming it helps. atomic.Int64, atomic.Bool and friends are lock-free and appropriate for counters and flags, but they do not compose: two atomic operations are not one atomic operation.The Go community's rule of thumb, from the standard library's own comments: use channels for passing ownership of data and coordinating the flow of work; use mutexes for protecting shared state with simple invariants, especially caches and counters. A mutex around a counter is clear and fast. A channel used as a lock is clever and slow. Our project uses channels for ant-to-world communication (ownership handover) and mutexes and atomics for the metrics package (shared counters). Both, deliberately.
Add an Errors int field to Counters, protected by the same mutex, and a method IncError() that increments it. Then write a second version of Record, called RecordUnsafe, that locks and unlocks manually — c.mu.Lock(), the two increments, c.mu.Unlock() — with no defer at all. Explain the one situation in which RecordUnsafe is a real bug that the defer version would not have been.
type Counters struct {
mu sync.Mutex
delivered int
byAnt map[int]int
Errors int
}
func (c *Counters) IncError() {
c.mu.Lock()
defer c.mu.Unlock()
c.Errors++
}
func (c *Counters) RecordUnsafe(antID int) {
c.mu.Lock()
c.delivered++
c.byAnt[antID]++
c.mu.Unlock()
}
As written, RecordUnsafe behaves identically to Record. The difference appears the moment something between Lock and the manual Unlock panics — c.byAnt[antID]++ on a nil map, say, if the caller forgot to initialise byAnt. The panic skips straight past the manual c.mu.Unlock(), the mutex stays locked forever, and every future caller of any method that locks c.mu blocks permanently — a single panic anywhere in the critical section turns into a total, silent deadlock of the whole Counters. defer c.mu.Unlock() runs during the panic's unwind regardless, so the mutex is released even though the program still crashes. This is exactly why "lock, defer unlock" as the very next line is the standing rule rather than a style preference.
context.Context carries a cancellation signal and a deadline down through a call tree. Every long-running operation in modern Go accepts one as its first parameter. It is the standard answer to "how do I stop 50,000 goroutines at once".
func run(ctx context.Context, id int) error {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done(): // closed when cancelled
return ctx.Err() // context.Canceled or DeadlineExceeded
case <-ticker.C:
// do one unit of work
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // always; releases resources
for i := range 1000 {
go run(ctx, i)
}
time.Sleep(time.Second)
cancel() // all 1000 goroutines see this
}
context.Background() is the empty root context, created in main. context.TODO() is the same thing with a note to yourself. WithCancel, WithTimeout, and WithDeadline derive children. Cancelling a parent cancels every descendant. That tree structure is why one cancel() can stop a whole subsystem.ctx.Done() returns a channel that is closed on cancellation. Closing is a broadcast, which is how one call reaches thousands of waiters instantly.defer cancel(), even for a timeout context that will expire on its own, or you leak a timer and a goroutine.ctx. Do not store it in a struct. Do not pass nil. ctx.Done() will not stop. Go cannot kill a goroutine, and there is no equivalent of Erlang's exit(Pid, kill). This limitation shapes Milestone 8.Write afterN(ctx context.Context, n int, work func()) error that calls work() up to n times, sleeping 10 ms between calls, and returns ctx.Err() the moment ctx is cancelled instead of making the remaining calls. It returns nil if all n calls complete first. Call it with a context that times out after 25 ms and n set to 10, and confirm work runs only two or three times, not ten.
func afterN(ctx context.Context, n int, work func()) error {
for i := 0; i < n; i++ {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
work()
if i < n-1 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(10 * time.Millisecond):
}
}
}
return nil
}
With a 25 ms timeout and a 10 ms gap between calls, work fires immediately (0 ms), again at 10 ms, again at 20 ms, and the wait before a fourth call would land at 30 ms — past the deadline — so the select on ctx.Done() wins instead and afterN returns context.DeadlineExceeded having called work two or three times depending on exact scheduling, never all ten. The check at the top of the loop matters too: without it, a context already cancelled before the first call would still let one call through, which is usually not what "cancelled" is supposed to mean.
Testing is in the standard library and requires no dependencies. A test is a function named TestXxx taking *testing.T, in a file ending _test.go, in the same package as the code.
internal/world/grid.go with tests in internal/world/grid_test.go:
package world
import "testing"
func TestGridSetAndAt(t *testing.T) {
g := NewGrid(4, 3)
g.Set(2, 1, 7)
if got := g.At(2, 1); got != 7 {
t.Errorf("At(2,1) = %d, want 7", got)
}
}
// table-driven tests: the dominant Go style
func TestGridBounds(t *testing.T) {
g := NewGrid(4, 3)
cases := []struct {
name string
x, y int
want int
}{
{"inside", 0, 0, 0},
{"negative x", -1, 0, 0},
{"beyond width", 4, 0, 0},
{"beyond height", 0, 3, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := g.At(tc.x, tc.y); got != tc.want {
t.Errorf("At(%d,%d) = %d, want %d", tc.x, tc.y, got, tc.want)
}
})
}
}
go test ./... # everything
go test -v ./internal/world # verbose, one line per test
go test -run TestGridBounds/negative ./internal/world # one subtest
go test -race ./... # with the race detector (slower, essential)
go test -cover ./... # coverage percentage
go test -bench=. ./... # run benchmarks
t.Errorf records a failure and continues; t.Fatalf records and stops that test immediately. Use Fatalf when continuing would panic.got X, want Y. Follow it; every Go reviewer expects it.t.Run creates subtests with their own names, which makes failures pinpoint-able and lets you run one case.world can see unexported identifiers. A file declaring package world_test sees only the public API, which is a useful way to test that your API is usable. testify is common, but plain if statements are the default and are fine.
Write a small program that models a very simple version of what we are about to build. Requirements:
Report struct with an AntID int and a Found bool.ant(ctx context.Context, id int, out chan<- Report) that sends a report every 50 ms until the context is cancelled, with Found true roughly one time in four. It must return promptly on cancellation and must not block forever if nobody is reading out. main that starts 100 ants, collects reports for 500 ms, then cancels, waits for every goroutine to finish, and prints the total number of reports and the number of finds.go run -race ..Hints: one select in the ant with three cases; a WaitGroup; a buffered out channel plus a default case on the send if you want to avoid blocking; the collector goroutine should stop only after the channel is closed, and the channel should be closed only after all senders are done.
package main
import (
"context"
"fmt"
"math/rand"
"sync"
"time"
)
type Report struct {
AntID int
Found bool
}
func ant(ctx context.Context, id int, out chan<- Report) {
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
r := Report{AntID: id, Found: rand.IntN(4) == 0}
select {
case out <- r: // delivered
case <-ctx.Done(): // cancelled while waiting to send
return
default: // collector is behind: drop it
}
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
out := make(chan Report, 1024)
var wg sync.WaitGroup
for i := range 100 {
wg.Add(1)
go func() {
defer wg.Done()
ant(ctx, i, out)
}()
}
// close out only after every sender has returned
go func() {
wg.Wait()
close(out)
}()
var total, finds int
done := time.After(500 * time.Millisecond)
collect:
for {
select {
case r, ok := <-out:
if !ok {
break collect
}
total++
if r.Found {
finds++
}
case <-done:
cancel() // tell the ants to stop; keep draining
}
}
fmt.Printf("reports=%d finds=%d\n", total, finds)
}
Points worth studying in this solution, because every one of them recurs in the real project:
select with ctx.Done() and default makes it both cancellable and non-blocking.break inside a select breaks the select, not the enclosing for. You need a label, here collect:, to leave the loop. This trips up everyone once. cancel(), the loop keeps receiving until the channel closes, so no reports are lost and no sender is left blocked. Exiting immediately would leak goroutines.rand.IntN is the modern math/rand/v2 spelling; on older Go it is rand.Intn from math/rand. append return a value instead of modifying in place?var m map[string]int. Which of reading, writing, and len works?select? wg.Add be called before go rather than inside the goroutine?ctx context.Context but runs a tight arithmetic loop for ten seconds. Does cancel() stop it? Why not? errors.Is and errors.As, and when does == on errors fail? Everything in this crash course exists in other languages. Go's contribution is not novelty, it is proportion: the language is small enough to hold in your head after a week, and the concurrency primitives are first-class rather than bolted on. You have now seen essentially all of Go except generics and reflection. Compare that to the fraction of C++ or Scala you would know after the same effort.
The honest cost: verbosity in error handling, no sum types so "this is either A or B" is awkward, and a type system that will feel thin if you come from Haskell or Rust. For a simulation whose difficulty is concurrency rather than data modelling, that trade lands well.
The next instalment starts building. Milestone 1 creates the module properly, defines World, Grid and Ant, and runs a single ant through a deterministic tick loop with a seeded random source, a command-line flag or two, and the first real tests. It is intentionally not concurrent, because the whole point of Milestone 4 is to break it.
Before then, two things worth doing:
-race. If you have not seen race detector output yet, deliberately introduce a shared counter incremented by all 100 goroutines without synchronisation, and read what it tells you.antfarm module and commit the empty skeleton. Milestone 1 assumes it exists.