The go journey - understand gorutinies - car game

The go journey - understand gorutinies - car game
Photo by Jonathan Chng / Unsplash

It's a really long time since I attracted to game, but I must admit that i always have a sweet spot on those simple ones, that aren't very sophisticated maybe even a bit tawdry. Considering what's above I wanted to create with Go a simple game.

The main plan was to create a game with some exploration of Goroutine concept, we'll explore how mutex locks work by examining a real-world example: a local multiplayer racing game built with Go and WebSockets.


This is a deliberately simple architecture - just one WebSocket connection handling two players in the same browser. Yet even this simple setup requires proper lock management to avoid race conditions. The simplicity makes it perfect for understanding the fundamentals of concurrent programming in Go.


The Challenge: Concurrent Access to Shared Data
In our racing game, we have multiple goroutines running simultaneously. But where are these goroutines created?


1. Implicit Goroutine from HTTP Server
In `main.go`, when we start the server:

  func main() {    
    fs := http.FileServer(http.Dir("./static"))
    http.Handle("/", fs)
    http.HandleFunc("/ws", handleLocalGame)
    log.Println("Server starting on :8080")    
    http.ListenAndServe(":8080", nil)
  }

Behind the scenes: Go's http.ListenAndServe automatically spawns a new goroutine for each incoming connection.
When the browser connects, it creates ONE goroutine that handles both players:


func handleLocalGame(w http.ResponseWriter, r *http.Request) {
    conn, err := upgrader.Upgrade(w, r, nil)

    room := game.NewRoom("local")

    player1 := game.NewPlayer("1", conn, 100.0, 200.0)
    player2 := game.NewPlayer("2", conn, 100.0, 350.0)

    room.AddPlayer(player1)
    room.AddPlayer(player2)

    for {
        _, message, err := conn.ReadMessage()
        handleBothPlayersInput(room, message)
    }
}

2. Explicit Goroutine: The Game Loop
In game/room.go, when the second player joins:

  func (r *Room) AddPlayer(player *Player) bool {
    r.mu.Lock()
    defer r.mu.Unlock()

    r.Players = append(r.Players, player)

    if len(r.Players) == 2 {
        r.Started = true
        go r.StartGameLoop()  ← HERE! Explicit goroutine creation
    }

    return true
}

This goroutine runs continuously, updating game state:

  func (r *Room) StartGameLoop() {
    r.ticker = time.NewTicker(50 * time.Millisecond)
    defer r.ticker.Stop()

    for {
        select {
        case <-r.ticker.C:
            r.Update()           
            r.BroadcastState()  
        case <-r.stopChan:
            return
        }
    }
}

Summary of Active Goroutines
For our local multiplayer game, we have 3 goroutines running:
1. Main goroutine - Runs main() and the HTTP server
2. Input goroutine - Handles the WebSocket connection (both players' input)
3. Game Loop goroutine - Updates positions and broadcasts state (explicitly created with `go`)


┌─────────────────────────────────────────────────────────┐
│                    Main Goroutine                       │
│              http.ListenAndServe(":8080")               │ 
└─────────────────────────────────────────────────────────┘
                          │
                          ▼
              ┌──────────────────────┐
              │   Input Goroutine    │
              │  (Both Players)      │
              │                      │
              │  for {               │
              │   ReadMessage()      │
              │   HandleInput(P1)    │
              │   HandleInput(P2)    │◄──────┐
              │  }                   │  Race!│
              └──────────────────────┘       │
                          │                  │
                          ▼                  │
                  ┌──────────────────┐       │
                  │   Game Loop      │◄──────┘
                  │   Goroutine      │
                  │                  │
                  │  for {           │
                  │   Update()       │
                  │   Broadcast()    │
                  │  }               │
                  └──────────────────┘
                          │
                  Accesses Shared:
                  - player1.Speed, player2.Speed
                  - player1.X/Y, player2.X/Y
                  - room.Players
                  - room.Winner


Let's take a look at our examples in game, the input goroutine and game loop goroutine both access the same player data simultaneously. Without proper synchronization, this leads to race conditions.
You may ask what is race condition? A race condition occurs when multiple goroutines access shared data simultaneously, and at least one of them modifies the data. Let's see a concrete example from our code:
Race Condition Example

func handleLocalGame(...) {
    for {
        _, message, err := conn.ReadMessage()

        room.HandlePlayerInput("1", p1Input)  // ← Writes to player1.Speed

        room.HandlePlayerInput("2", p2Input)  // ← Writes to player2.Speed
    }
}

and update of Players position in the game:

func (r *Room) Update() {
    for _, player := range r.Players {
        player.UpdatePosition()  
    }
}

Which can lead to access both the same data, look at the timeline:

Timeline without locks:
───────────────────────────────────────────────────────────────
Time 1: Game Loop reads player.Speed = 5.0
        Calculates new position...
Time 2: Input Goroutine sets player.Speed = 6.0 (player pressed W)
Time 3: Game Loop writes player.Speed = 4.9 (friction applied)
        ← WRONG! Overwrites the acceleration from input!

The Solution: Mutex Locks
Go provides the sync package with two types of locks:
1. Mutex (sync.Mutex)
A Mutex (mutual exclusion lock) ensures only one goroutine can access the protected data at a time.

Let's see that in action below :

type Player struct {
    ID               string
    PositionX        float64
    PositionY        float64
    Speed            float64
    Angle            float64
    LastActivity     time.Time
    stateMutex       sync.Mutex
}

func (p *Player) UpdatePosition() {
    p.stateMutex.Lock()
    defer p.stateMutex.Unlock()

    angleInRadians := p.Angle * (math.Pi / 180)
    p.PositionX += math.Cos(angleInRadians) * p.Speed
    p.PositionY += math.Sin(angleInRadians) * p.Speed

    p.Speed *= SpeedDecayRate
    if math.Abs(p.Speed) < MinimumSpeed {
        p.Speed = 0
    }
}

How it works:
- Lock()
- Acquires the lock (waits if another goroutine holds it)
- defer Unlock()
- Releases the lock when the function returns
- Only one goroutine can hold the lock at a time

2. RWMutex (sync.RWMutex)
An RWMutex is a reader-writer lock that allows multiple readers OR one writer.

type Room struct {
    ID                string
    Players           []*Player
    Winner            string
    Checkpoints       []Checkpoint
    InactivityTimeout time.Duration
    stateMutex        sync.RWMutex
}

func (r *Room) BroadcastGameState() {
    r.stateMutex.RLock()
    defer r.stateMutex.RUnlock()

    gameState := r.SerializeRoomState()
    data, _ := json.Marshal(gameState)

    for _, player := range r.Players {
        err := player.Conn.WriteMessage(websocket.TextMessage, data)
        if err != nil {
            log.Printf("Error sending to player %s: %v", player.ID, err)
        }
    }
}

func (r *Room) UpdateGameState() {
    r.stateMutex.Lock()
    defer r.stateMutex.Unlock()

    for _, player := range r.Players {
        player.UpdatePosition()
    }
}

RLock vs Lock:
- RLock() - Multiple goroutines can read simultaneously
- Lock() - Exclusive access, blocks all readers and writers


Real-World Example: Player Input Handling
Let's trace through what happens when Player 1 presses the "W" key:


func (r *Room) HandlePlayerInput(playerID string, input map[string]bool) {
    r.stateMutex.RLock()
    defer r.stateMutex.RUnlock()

    for _, player := range r.Players {
        if player.ID == playerID {
            player.ProcessMovementInput(input["up"], input["down"],
                                       input["left"], input["right"])
            break
        }
    }
}

func (p *Player) ProcessMovementInput(up, down, left, right bool) {
    p.stateMutex.Lock()
    defer p.stateMutex.Unlock()

    if up || down || left || right {
        p.LastActivity = time.Now()
    }

    if up {
        p.Speed += CarAcceleration
        if p.Speed > CarMaxSpeed {
            p.Speed = CarMaxSpeed
        }
    }
    if down {
        p.Speed -= CarAcceleration
        if p.Speed < CarReverseSpeed {
            p.Speed = CarReverseSpeed
        }
    }

    if math.Abs(p.Speed) > 0.5 {
        if left {
            p.Angle -= CarTurnSpeed
        }
        if right {
            p.Angle += CarTurnSpeed
        }
    }
}

Let's me explain a bit the flow:

1. Input handler acquires read lock on room to find the player
2. Multiple input handlers can search simultaneously (RLock allows this)
3. Once found, player's write lock is acquired to modify speed
4. Other goroutines wait until the speed update is complete
5. Locks release automatically via defer


What are good practise for using locks, I've wrote them below:


1. Always Use defer for Unlock

func (p *Player) UpdatePosition() {
    p.mu.Lock()
    defer p.mu.Unlock()  // Always executes, even if panic occurs

    // Your code here
}

2. Keep Critical Sections Small


func (p *Player) GetState() map[string]interface{} {    
  p.stateMutex.Lock()    
  posX := p.PositionX   
  posY := p.PositionY    
  speed := p.Speed    
  angle := p.Angle    
  p.stateMutex.Unlock()  // Release lock early
    // Do expensive operations outside the lock    
    return map[string]interface{}{
    "id":    p.ID,
    "x":     posX,
    "y":     posY,
    "speed": speed,
    "angle": angle,
    }
  }

3. Use RWMutex for Read-Heavy Workloads
If you have many reads and few writes, RWMutex improves performance:

// Many goroutines can call this simultaneously
func (r *Room) GetPlayerCount() int {
    r.stateMutex.RLock()
    defer r.stateMutex.RUnlock()
    return len(r.Players)
}

// Only one goroutine can call this at a time
func (r *Room) AddPlayer(p *Player) bool {
    r.stateMutex.Lock()
    defer r.stateMutex.Unlock()

    if len(r.Players) >= 2 {
        return false
    }

    r.Players = append(r.Players, p)
    return true
}

4. Avoid Deadlocks
Deadlocks occur when goroutines wait for each other's locks:

func (p1 *Player) Trade(p2 *Player) {
    p1.stateMutex.Lock()
    p2.stateMutex.Lock()  // If another goroutine locks in reverse order: deadlock!
    // ...
}

// Always acquire locks in consistent order
func (p1 *Player) Trade(p2 *Player) {
    if p1.ID < p2.ID {
        p1.stateMutex.Lock()
        p2.stateMutex.Lock()
    } else {
        p2.stateMutex.Lock()
        p1.stateMutex.Lock()
    }
    defer p1.stateMutex.Unlock()
    defer p2.stateMutex.Unlock()
    // ...
}

Performance Impact
In our racing game running at 20 FPS with 2 players:
Without locks:
- Race conditions
- Data corruption
- Unpredictable behavior
- Possible crashes
With locks:
- Safe concurrent access
- Predictable behavior
- Minimal overhead (~100 nanoseconds per lock/unlock)
- Smooth gameplay
The overhead of locks is negligible compared to network I/O and rendering.


Detecting Race Conditions
Many new to Go probably won't know that go has build-in mechanism for detection race conditions.


go run -race main.go

This should help you to detect race conditions in development on early stage.

WARNING: DATA RACE
Write at 0x00c0001a2050 by goroutine 7:
  main.(*Player).UpdatePosition()
      /path/to/player.go:42 +0x12c

Previous read at 0x00c0001a2050 by goroutine 8:
  main.(*Player).GetState()
      /path/to/player.go:95 +0x84

Always run your tests with -race flag during development!


Conclusion
Mutex locks are essential for building safe concurrent applications in Go. In our local multiplayer racing game example, we discovered:
Where Goroutines Come From
Our simple game has just 3 goroutines:
- Main goroutine: Runs the HTTP server
- Implicit goroutine: Created by HTTP server when browser connects (1 connection = both players)
- Explicit goroutine:We create with go r.StartGameLoop() for the game loop
This simple architecture shows that even with minimal goroutines, you still need locks!


Why Locks Are Critical
Without locks, race conditions cause:
- Lost player inputs (pressing "W" doesn't accelerate)
- Corrupted positions (players teleport)
- Wrong game state (incorrect winner)
With locks, the 2 concurrent goroutines coordinate safely:
- Input goroutine writes player speeds when keys are pressed
- Game loop goroutine reads/writes positions and speeds
- Locks ensure they never corrupt each other's data



Key Takeaways
1. Use sync.Mutex for exclusive access to shared data
2. Use sync.RWMutex when you have many readers and few writers
3. Always use defer to unlock, even in case of panics
4. Keep critical sections small to maximize concurrency
5. Test with race flag to catch race conditions early
6. Track connection lifecycle with activity timestamps to handle inactive players
7. Use multiple tickers in event loops for different frequency tasks


The most important lesson: Be aware of implicit concurrency! Go's HTTP server creates goroutines for you. Even in a simple local multiplayer game with just one connection, you have multiple goroutines accessing shared data. Always ask yourself: "Could another goroutine access this data simultaneously?"


Simple Architecture, Real Concurrency
Our game proves that you don't need a complex system to need locks. With just:
- 1 WebSocket connection
- 2 players in the same browser- 1 game loop with multiple tickers
- Inactivity timeout monitoring
We still have race conditions that require proper synchronization. Plus, we've added real-world connection management with timeouts to handle inactive players gracefully. This is the reality of concurrent programming in Go!

Source Code
The complete source code for this racing game is available in this repository. Feel free to explore how locks are used throughout the codebase!