The Tight Coupling Problem

Imagine your game needs to handle player damage. The naive approach couples everything together:

func TakeDamage(player *Player, damage int) {
    player.Health -= damage

    // Tightly coupled to UI
    ui.UpdateHealthBar(player.Health)

    // Tightly coupled to audio
    audio.PlaySound("hurt.wav")

    // Tightly coupled to achievements
    if player.Health <= 10 {
        achievements.Unlock("NEAR_DEATH")
    }

    // Tightly coupled to analytics
    analytics.TrackEvent("player_damaged", damage)
}

// Problem: Adding any system requires modifying this function
// Problem: Testing becomes nightmare
// Problem: Can't disable/enable systems easily

The Observer pattern and Event Buses solve this by decoupling event producers from consumers.

Observer Pattern Basics

The Observer pattern lets objects subscribe to and receive notifications about events:

package main

import (
    "fmt"
    "sync"
)

// Observer interface
type Observer interface {
    OnNotify(event interface{})
}

// Subject maintains list of observers
type Subject struct {
    observers []Observer
    mu        sync.RWMutex
}

func (s *Subject) Attach(observer Observer) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.observers = append(s.observers, observer)
}

func (s *Subject) Detach(observer Observer) {
    s.mu.Lock()
    defer s.mu.Unlock()

    for i, obs := range s.observers {
        if obs == observer {
            s.observers = append(s.observers[:i], s.observers[i+1:]...)
            break
        }
    }
}

func (s *Subject) Notify(event interface{}) {
    s.mu.RLock()
    observers := make([]Observer, len(s.observers))
    copy(observers, s.observers)
    s.mu.RUnlock()

    for _, observer := range observers {
        observer.OnNotify(event)
    }
}

Building a Type-Safe Event System

Let’s create a type-safe event system using Go generics:

package main

import (
    "fmt"
    "sync"
)

// Event types
type PlayerDamagedEvent struct {
    PlayerID int
    Damage   int
    Health   int
}

type PlayerDiedEvent struct {
    PlayerID int
    Killer   string
}

type PlayerHealedEvent struct {
    PlayerID int
    Amount   int
    Health   int
}

type AchievementUnlockedEvent struct {
    PlayerID       int
    AchievementID  string
    AchievementName string
}

// EventListener is a generic listener function
type EventListener[T any] func(event T)

// EventBus manages subscriptions and event dispatch
type EventBus struct {
    listeners map[string][]interface{}
    mu        sync.RWMutex
}

func NewEventBus() *EventBus {
    return &EventBus{
        listeners: make(map[string][]interface{}),
    }
}

func (eb *EventBus) Subscribe(eventType string, listener interface{}) {
    eb.mu.Lock()
    defer eb.mu.Unlock()

    if eb.listeners[eventType] == nil {
        eb.listeners[eventType] = make([]interface{}, 0)
    }
    eb.listeners[eventType] = append(eb.listeners[eventType], listener)
}

func (eb *EventBus) Unsubscribe(eventType string, listener interface{}) {
    eb.mu.Lock()
    defer eb.mu.Unlock()

    listeners := eb.listeners[eventType]
    for i, l := range listeners {
        // Note: This won't work perfectly for function comparison
        // In production, use registration IDs or channels
        if &l == &listener {
            eb.listeners[eventType] = append(listeners[:i], listeners[i+1:]...)
            break
        }
    }
}

func (eb *EventBus) Publish(eventType string, event interface{}) {
    eb.mu.RLock()
    listeners := make([]interface{}, len(eb.listeners[eventType]))
    copy(listeners, eb.listeners[eventType])
    eb.mu.RUnlock()

    for _, listener := range listeners {
        // Type assertion and call
        switch fn := listener.(type) {
        case func(PlayerDamagedEvent):
            if e, ok := event.(PlayerDamagedEvent); ok {
                fn(e)
            }
        case func(PlayerDiedEvent):
            if e, ok := event.(PlayerDiedEvent); ok {
                fn(e)
            }
        case func(PlayerHealedEvent):
            if e, ok := event.(PlayerHealedEvent); ok {
                fn(e)
            }
        case func(AchievementUnlockedEvent):
            if e, ok := event.(AchievementUnlockedEvent); ok {
                fn(e)
            }
        }
    }
}

// Better approach: Typed event publishers
type TypedEventBus struct {
    bus *EventBus
}

func NewTypedEventBus() *TypedEventBus {
    return &TypedEventBus{
        bus: NewEventBus(),
    }
}

func (teb *TypedEventBus) OnPlayerDamaged(listener func(PlayerDamagedEvent)) {
    teb.bus.Subscribe("player_damaged", listener)
}

func (teb *TypedEventBus) OnPlayerDied(listener func(PlayerDiedEvent)) {
    teb.bus.Subscribe("player_died", listener)
}

func (teb *TypedEventBus) OnPlayerHealed(listener func(PlayerHealedEvent)) {
    teb.bus.Subscribe("player_healed", listener)
}

func (teb *TypedEventBus) OnAchievementUnlocked(listener func(AchievementUnlockedEvent)) {
    teb.bus.Subscribe("achievement_unlocked", listener)
}

func (teb *TypedEventBus) PublishPlayerDamaged(event PlayerDamagedEvent) {
    teb.bus.Publish("player_damaged", event)
}

func (teb *TypedEventBus) PublishPlayerDied(event PlayerDiedEvent) {
    teb.bus.Publish("player_died", event)
}

func (teb *TypedEventBus) PublishPlayerHealed(event PlayerHealedEvent) {
    teb.bus.Publish("player_healed", event)
}

func (teb *TypedEventBus) PublishAchievementUnlocked(event AchievementUnlockedEvent) {
    teb.bus.Publish("achievement_unlocked", event)
}

Real-World Example: Game Systems

Let’s build decoupled game systems using the event bus:

package main

import (
    "fmt"
    "time"
)

// UISystem listens for events and updates the UI
type UISystem struct {
    eventBus *TypedEventBus
}

func NewUISystem(eventBus *TypedEventBus) *UISystem {
    ui := &UISystem{eventBus: eventBus}

    // Subscribe to events
    eventBus.OnPlayerDamaged(ui.onPlayerDamaged)
    eventBus.OnPlayerHealed(ui.onPlayerHealed)
    eventBus.OnPlayerDied(ui.onPlayerDied)

    return ui
}

func (ui *UISystem) onPlayerDamaged(event PlayerDamagedEvent) {
    fmt.Printf("[UI] Health bar updated: %d HP (-%d damage)\n", event.Health, event.Damage)
    fmt.Printf("[UI] Screen flash effect triggered\n")
}

func (ui *UISystem) onPlayerHealed(event PlayerHealedEvent) {
    fmt.Printf("[UI] Health bar updated: %d HP (+%d healing)\n", event.Health, event.Amount)
}

func (ui *UISystem) onPlayerDied(event PlayerDiedEvent) {
    fmt.Printf("[UI] Showing death screen...\n")
    fmt.Printf("[UI] Killed by: %s\n", event.Killer)
}

// AudioSystem handles sound effects
type AudioSystem struct {
    eventBus *TypedEventBus
}

func NewAudioSystem(eventBus *TypedEventBus) *AudioSystem {
    audio := &AudioSystem{eventBus: eventBus}

    eventBus.OnPlayerDamaged(audio.onPlayerDamaged)
    eventBus.OnPlayerHealed(audio.onPlayerHealed)
    eventBus.OnPlayerDied(audio.onPlayerDied)
    eventBus.OnAchievementUnlocked(audio.onAchievementUnlocked)

    return audio
}

func (a *AudioSystem) onPlayerDamaged(event PlayerDamagedEvent) {
    if event.Health <= 20 {
        fmt.Printf("[Audio] Playing critical health heartbeat sound\n")
    } else {
        fmt.Printf("[Audio] Playing hurt sound: hurt_%d.wav\n", event.Damage%3)
    }
}

func (a *AudioSystem) onPlayerHealed(event PlayerHealedEvent) {
    fmt.Printf("[Audio] Playing healing sound\n")
}

func (a *AudioSystem) onPlayerDied(event PlayerDiedEvent) {
    fmt.Printf("[Audio] Playing death music\n")
    fmt.Printf("[Audio] Stopping all gameplay sounds\n")
}

func (a *AudioSystem) onAchievementUnlocked(event AchievementUnlockedEvent) {
    fmt.Printf("[Audio] Playing achievement fanfare\n")
}

// AchievementSystem tracks player achievements
type AchievementSystem struct {
    eventBus            *TypedEventBus
    totalDamageTaken    int
    nearDeathExperiences int
}

func NewAchievementSystem(eventBus *TypedEventBus) *AchievementSystem {
    ach := &AchievementSystem{eventBus: eventBus}

    eventBus.OnPlayerDamaged(ach.onPlayerDamaged)
    eventBus.OnPlayerDied(ach.onPlayerDied)

    return ach
}

func (a *AchievementSystem) onPlayerDamaged(event PlayerDamagedEvent) {
    a.totalDamageTaken += event.Damage

    if event.Health <= 10 && event.Health > 0 {
        a.nearDeathExperiences++
        fmt.Printf("[Achievement] Near death experience #%d\n", a.nearDeathExperiences)

        if a.nearDeathExperiences >= 5 {
            a.eventBus.PublishAchievementUnlocked(AchievementUnlockedEvent{
                PlayerID:       event.PlayerID,
                AchievementID:  "LIVING_DANGEROUSLY",
                AchievementName: "Living Dangerously",
            })
        }
    }

    if a.totalDamageTaken >= 1000 {
        a.eventBus.PublishAchievementUnlocked(AchievementUnlockedEvent{
            PlayerID:       event.PlayerID,
            AchievementID:  "PUNCHING_BAG",
            AchievementName: "Punching Bag",
        })
    }
}

func (a *AchievementSystem) onPlayerDied(event PlayerDiedEvent) {
    fmt.Printf("[Achievement] Player survived for %d near-death experiences before dying\n",
        a.nearDeathExperiences)
}

// AnalyticsSystem tracks game metrics
type AnalyticsSystem struct {
    eventBus *TypedEventBus
}

func NewAnalyticsSystem(eventBus *TypedEventBus) *AnalyticsSystem {
    analytics := &AnalyticsSystem{eventBus: eventBus}

    eventBus.OnPlayerDamaged(analytics.onPlayerDamaged)
    eventBus.OnPlayerDied(analytics.onPlayerDied)
    eventBus.OnAchievementUnlocked(analytics.onAchievementUnlocked)

    return analytics
}

func (a *AnalyticsSystem) onPlayerDamaged(event PlayerDamagedEvent) {
    fmt.Printf("[Analytics] Event logged: player_damaged (damage=%d, health=%d)\n",
        event.Damage, event.Health)
}

func (a *AnalyticsSystem) onPlayerDied(event PlayerDiedEvent) {
    fmt.Printf("[Analytics] Event logged: player_died (killer=%s)\n", event.Killer)
}

func (a *AnalyticsSystem) onAchievementUnlocked(event AchievementUnlockedEvent) {
    fmt.Printf("[Analytics] Event logged: achievement_unlocked (%s)\n",
        event.AchievementID)
}

// Game manages the game state and publishes events
type Game struct {
    eventBus *TypedEventBus
    health   int
    playerID int
}

func NewGame(eventBus *TypedEventBus) *Game {
    return &Game{
        eventBus: eventBus,
        health:   100,
        playerID: 1,
    }
}

func (g *Game) TakeDamage(damage int, source string) {
    g.health -= damage

    if g.health <= 0 {
        g.health = 0
        g.eventBus.PublishPlayerDied(PlayerDiedEvent{
            PlayerID: g.playerID,
            Killer:   source,
        })
        return
    }

    g.eventBus.PublishPlayerDamaged(PlayerDamagedEvent{
        PlayerID: g.playerID,
        Damage:   damage,
        Health:   g.health,
    })
}

func (g *Game) Heal(amount int) {
    g.health += amount
    if g.health > 100 {
        g.health = 100
    }

    g.eventBus.PublishPlayerHealed(PlayerHealedEvent{
        PlayerID: g.playerID,
        Amount:   amount,
        Health:   g.health,
    })
}

func main() {
    fmt.Println("=== Event-Driven Game Systems Demo ===\n")

    // Create event bus
    eventBus := NewTypedEventBus()

    // Initialize all systems (they auto-subscribe to events)
    NewUISystem(eventBus)
    NewAudioSystem(eventBus)
    NewAchievementSystem(eventBus)
    NewAnalyticsSystem(eventBus)

    // Create game
    game := NewGame(eventBus)

    // Simulate gameplay
    fmt.Println("--- Combat Sequence ---\n")

    time.Sleep(500 * time.Millisecond)
    game.TakeDamage(30, "Goblin")
    fmt.Println()

    time.Sleep(500 * time.Millisecond)
    game.TakeDamage(40, "Orc")
    fmt.Println()

    time.Sleep(500 * time.Millisecond)
    game.TakeDamage(25, "Troll")
    fmt.Println()

    time.Sleep(500 * time.Millisecond)
    game.Heal(20)
    fmt.Println()

    time.Sleep(500 * time.Millisecond)
    game.TakeDamage(15, "Skeleton")
    fmt.Println()

    time.Sleep(500 * time.Millisecond)
    game.TakeDamage(8, "Spider")
    fmt.Println()

    time.Sleep(500 * time.Millisecond)
    game.TakeDamage(5, "Rat")
    fmt.Println()

    time.Sleep(500 * time.Millisecond)
    game.TakeDamage(3, "Bat")
    fmt.Println()

    time.Sleep(500 * time.Millisecond)
    game.TakeDamage(2, "Mosquito")
    fmt.Println()

    time.Sleep(500 * time.Millisecond)
    game.TakeDamage(50, "Dragon")
    fmt.Println()

    fmt.Println("=== Demo Complete ===")
}
graph TD Game[Game Logic]:::lightBlue EventBus[Event Bus]:::lightYellow UI[UI System]:::lightGreen Audio[Audio System]:::lightGreen Achievements[Achievement System]:::lightGreen Analytics[Analytics System]:::lightGreen Game -->|Publish Events| EventBus EventBus -->|Notify| UI EventBus -->|Notify| Audio EventBus -->|Notify| Achievements EventBus -->|Notify| Analytics Achievements -.Publish Achievement Event.-> EventBus classDef lightBlue fill:#87CEEB,stroke:#4682B4,stroke-width:2px,color:#000 classDef lightYellow fill:#FFFFE0,stroke:#FFD700,stroke-width:2px,color:#000 classDef lightGreen fill:#90EE90,stroke:#228B22,stroke-width:2px,color:#000

Advanced: Async Event Bus with Channels

For high-performance scenarios, use channels for async event delivery:

type AsyncEventBus struct {
    events chan interface{}
    done   chan struct{}
}

func NewAsyncEventBus() *AsyncEventBus {
    aeb := &AsyncEventBus{
        events: make(chan interface{}, 100),
        done:   make(chan struct{}),
    }

    go aeb.processEvents()

    return aeb
}

func (aeb *AsyncEventBus) processEvents() {
    for {
        select {
        case event := <-aeb.events:
            // Dispatch to appropriate handlers
            aeb.dispatch(event)

        case <-aeb.done:
            return
        }
    }
}

func (aeb *AsyncEventBus) Publish(event interface{}) {
    select {
    case aeb.events <- event:
    default:
        // Channel full, drop event or handle overflow
        fmt.Println("Event queue full!")
    }
}

func (aeb *AsyncEventBus) Shutdown() {
    close(aeb.done)
}

func (aeb *AsyncEventBus) dispatch(event interface{}) {
    // Dispatch logic here
}

Advanced: Priority Events

Some events need to be processed before others:

type PriorityEvent struct {
    Priority int
    Event    interface{}
}

type PriorityEventBus struct {
    events []PriorityEvent
    mu     sync.Mutex
}

func (peb *PriorityEventBus) Publish(event interface{}, priority int) {
    peb.mu.Lock()
    defer peb.mu.Unlock()

    pe := PriorityEvent{Priority: priority, Event: event}

    // Insert in priority order
    i := 0
    for i < len(peb.events) && peb.events[i].Priority >= priority {
        i++
    }

    peb.events = append(peb.events[:i], append([]PriorityEvent{pe}, peb.events[i:]...)...)
}

func (peb *PriorityEventBus) ProcessNext() interface{} {
    peb.mu.Lock()
    defer peb.mu.Unlock()

    if len(peb.events) == 0 {
        return nil
    }

    event := peb.events[0]
    peb.events = peb.events[1:]

    return event.Event
}

Benefits of Event-Driven Architecture

  1. Decoupling: Systems don’t know about each other
  2. Extensibility: Add new listeners without changing existing code
  3. Testability: Test systems in isolation
  4. Flexibility: Enable/disable systems at runtime
  5. Maintainability: Each system has a single responsibility

When to Use Event Buses

Event buses are ideal for:

  • Game engines with multiple independent systems
  • UI frameworks with reactive updates
  • Plugin architectures
  • Distributed systems communication
  • Logging and analytics

Performance Considerations

  1. Synchronous vs Asynchronous: Sync is simpler, async avoids blocking
  2. Event Flooding: Limit event rates or use buffering
  3. Memory: Consider pooling event objects
  4. Ordering: Decide if event order matters
  5. Error Handling: Listener errors shouldn’t crash the bus

Thank you

The Observer pattern and Event Buses are essential for building decoupled, maintainable systems. By separating event producers from consumers, you create flexible architectures that scale beautifully with complexity. In Go, combining interfaces, channels, and generics gives you powerful, type-safe event systems.

Please drop an email at [email protected] if you would like to share any feedback or suggestions. Peace!