The go journey - understand gorutinies - car game
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 +0x84Always 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!