Before You Begin

  • Go installed (version 1.22 or later). Download
  • A text editor (VS Code with Go extension, GoLand, or vim with goimports)
  • Terminal comfort (navigating directories, running commands)

You don't need prior Go experience. If you've written any code before, you're ready. The goal is a working CLI tool that you can actually use to track scouting notes during a game.

Why a CLI Note-Taker?

Spreadsheets are great for static data, but terrible for rapid note entry during a live match. You can't easily add a player observation with a single command, search by team, or save to a file without macros. A Go CLI tool gives you fast startup, composability with Unix pipes, and full control over input validation. This tutorial uses a sports scouting theme so every concept feels natural: a struct is a player card, a slice is the team roster, and a method is a custom stat formula.

Step 1: Project Init – The Empty Bench

Start by creating a directory and initializing a Go module. A module is like naming your playbook – it keeps dependencies organized and avoids "works on my machine" surprises.

mkdir scout-notes
cd scout-notes
go mod init github.com/yourname/scout-notes

This creates go.mod. Think of it as the header row of your stats sheet. Now create main.go:

package main

import "fmt"

func main() {
    fmt.Println("Scout Notes v1.0 – Ready for the game")
}

Run go run main.go. If you see the message, your Go installation works. The project is alive.

Step 2: Define Columns with Structs

A player card has fields: Name, Team, Position, Rating, and a personal Note. In Go, these become fields in a struct. The struct defines the shape of one player.

type Player struct {
    Name     string
    Team     string
    Position string
    Rating   float64
    Note     string
}

Compare this to a paper scouting form:

| Name | Team | Position | Rating | Note | |------|------|----------|--------|------| | Leo Messi | Inter Miami | Forward | 9.2 | Unstoppable dribbling |

The struct's field names match the column headers. Each field has a type: string for text, float64 for numbers. Now populate one player inside main():

func main() {
    messi := Player{
        Name:     "Leo Messi",
        Team:     "Inter Miami",
        Position: "Forward",
        Rating:   9.2,
        Note:     "Unstoppable dribbling",
    }

    fmt.Printf("%s (%s, %s) – Rating: %.1f\n", messi.Name, messi.Team, messi.Position, messi.Rating)
}

Run it. You've printed your first structured player card.

Step 3: Build the Lineup with Slices

One player is just a single card. A real scout carries a whole roster. Go uses slices – dynamic arrays that grow as you add players.

var lineup []Player

lineup = append(lineup, Player{
    Name:     "Cristiano Ronaldo",
    Team:     "Al Nassr",
    Position: "Forward",
    Rating:   8.8,
    Note:     "Still lethal in the air",
})
lineup = append(lineup, Player{
    Name:     "Kylian Mbappé",
    Team:     "PSG",
    Position: "Forward",
    Rating:   9.0,
    Note:     "Blazing speed",
})

Now loop over the lineup:

for i, player := range lineup {
    fmt.Printf("%d. %s\n", i+1, player)
}

But player prints like {Cristiano Ronaldo Al Nassr Forward 8.8 Still lethal in the air} – ugly. We need a custom display formula.

Step 4: Add a Custom Formula (Methods)

In a spreadsheet, you'd write a formula like =CONCAT(A2," (",B2," - ",C2,") ",D2). In Go, you attach a method to the struct.

func (p Player) String() string {
    return fmt.Sprintf("%s (%s, %s) – Rating: %.1f\n  Note: %s", p.Name, p.Team, p.Position, p.Rating, p.Note)
}

Now fmt.Printf("%s\n", player) automatically calls String(). No explicit call needed. This is Go's version of a custom cell format. Run the loop again – each player prints as a neat card.

Step 5: Interactive Commands – The CLI Loop

A spreadsheet responds to mouse clicks. Our CLI responds to typed commands. We'll build a loop that reads user input and dispatches actions.

package main

import (
    "bufio"
    "encoding/json"
    "fmt"
    "os"
    "strings"
)

func main() {
    // Sample lineup
    lineup := []Player{
        {Name: "Erling Haaland", Team: "Man City", Position: "Forward", Rating: 9.1, Note: "Clinical finisher"},
        {Name: "Kevin De Bruyne", Team: "Man City", Position: "Midfielder", Rating: 9.0, Note: "Visionary passer"},
    }

    fmt.Println("Scout Notes CLI")
    fmt.Println("Commands: add, list, search, save, load, quit")

    scanner := bufio.NewScanner(os.Stdin)
    for {
        fmt.Print("> ")
        if !scanner.Scan() {
            break
        }
        cmd := strings.TrimSpace(scanner.Text())

        switch cmd {
        case "add":
            fmt.Print("Name: ")
            scanner.Scan()
            name := scanner.Text()
            fmt.Print("Team: ")
            scanner.Scan()
            team := scanner.Text()
            fmt.Print("Position: ")
            scanner.Scan()
            pos := scanner.Text()
            fmt.Print("Rating: ")
            scanner.Scan()
            rating := 0.0
            fmt.Sscanf(scanner.Text(), "%f", &rating)
            fmt.Print("Note: ")
            scanner.Scan()
            note := scanner.Text()

            lineup = append(lineup, Player{Name: name, Team: team, Position: pos, Rating: rating, Note: note})
            fmt.Println("Player added.")

        case "list":
            for i, p := range lineup {
                fmt.Printf("%d. %s\n", i+1, p)
            }

        case "search":
            fmt.Print("Search term (name or team): ")
            scanner.Scan()
            term := strings.ToLower(scanner.Text())
            for _, p := range lineup {
                if strings.Contains(strings.ToLower(p.Name), term) || strings.Contains(strings.ToLower(p.Team), term) {
                    fmt.Println(p)
                }
            }

        case "save":
            file, err := os.Create("lineup.json")
            if err != nil {
                fmt.Println("Error saving:", err)
                continue
            }
            defer file.Close()
            encoder := json.NewEncoder(file)
            encoder.SetIndent("", "  ")
            err = encoder.Encode(lineup)
            if err != nil {
                fmt.Println("Error encoding:", err)
            } else {
                fmt.Println("Lineup saved to lineup.json")
            }

        case "load":
            file, err := os.Open("lineup.json")
            if err != nil {
                fmt.Println("No saved lineup found.")
                continue
            }
            defer file.Close()
            decoder := json.NewDecoder(file)
            err = decoder.Decode(&lineup)
            if err != nil {
                fmt.Println("Error loading:", err)
            } else {
                fmt.Println("Lineup loaded.")
            }

        case "quit":
            fmt.Println("Goodbye, scout!")
            return

        default:
            fmt.Println("Unknown command. Try: add, list, search, save, load, quit")
        }
    }
}

This is a fully functional CLI note-taker. You can add players, list them, search by name or team, save to a JSON file, and load it back. The bufio.Scanner handles input line by line. The strings.TrimSpace avoids accidental whitespace commands.

Step 6: Making It Robust – File Persistence and Edge Cases

The save and load commands use encoding/json – Go's standard library for JSON. The encoder.SetIndent makes the file human-readable. The load command overwrites the current lineup, so you might want to confirm before loading. A simple improvement:

case "load":
    if len(lineup) > 0 {
        fmt.Print("Current lineup will be replaced. Continue? (y/n): ")
        scanner.Scan()
        if strings.ToLower(scanner.Text()) != "y" {
            continue
        }
    }
    // ... rest of load

Also handle the case where the user enters a non-numeric rating. The fmt.Sscanf returns an error; we should check it:

_, err := fmt.Sscanf(scanner.Text(), "%f", &rating)
if err != nil {
    fmt.Println("Invalid rating, setting to 0.0")
    rating = 0.0
}
Sponsored Deal

Common Issues

Scanner skips input after reading a number? The bufio.Scanner reads line by line, but if you mix fmt.Scan and scanner.Scan, buffer issues arise. Stick to one method. In this tutorial, we use scanner.Scan() for everything, then parse the string. This avoids the classic "newline left in buffer" problem.

JSON file not found? The load command expects lineup.json in the current working directory. If you run the program from a different directory, the file won't be found. Use absolute paths or os.Getwd() to make it robust.

Struct fields not exported? Go's JSON encoder only works with exported fields (capital letter). Our Player struct fields all start with uppercase, so they're fine. If you accidentally use lowercase, JSON will silently omit them.

How to add more commands? The switch statement is easy to extend. Add a case "delete": that prompts for an index and removes the player from the slice using append(slice[:i], slice[i+1:]...).

Next Steps

You've built a working CLI note-taker that stores scouting observations. The same pattern applies to any structured data: inventory, contacts, bookmarks. Try adding a rating command that calculates the average rating of all players. Or integrate with a CSV export. The power of Go is that this whole program compiles to a single binary – no dependencies, no runtime.

Run go build to create an executable. Share it with your fellow scouts. They'll never need a spreadsheet again.