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 ===")
}
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
- Decoupling: Systems don’t know about each other
- Extensibility: Add new listeners without changing existing code
- Testability: Test systems in isolation
- Flexibility: Enable/disable systems at runtime
- 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
- Synchronous vs Asynchronous: Sync is simpler, async avoids blocking
- Event Flooding: Limit event rates or use buffering
- Memory: Consider pooling event objects
- Ordering: Decide if event order matters
- 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!