Beyond Traditional State Machines

Most state machines are synchronous: you call a method, the state transitions, and you get a result. But what if your state machine needs to react to multiple concurrent events? What if state transitions should happen asynchronously based on timeouts, external signals, or user input?

Enter the select-based state machine: a pattern that combines Go’s channels and select statements to create responsive, event-driven state machines that can handle multiple inputs simultaneously.

The Traditional Synchronous Approach

Here’s a typical synchronous state machine:

type State int

const (
    Idle State = iota
    Processing
    Complete
)

type Machine struct {
    state State
}

func (m *Machine) Process() {
    switch m.state {
    case Idle:
        m.state = Processing
        // Do work...
        m.state = Complete
    case Processing:
        // Already processing
    case Complete:
        // Done
    }
}

// Problem: Blocking operations stall the entire machine
// Problem: Can't react to multiple events simultaneously
// Problem: Timeouts and cancellation are difficult

Event-Driven State Machines with Select

Let’s build a connection manager that handles multiple concurrent events:

package main

import (
    "context"
    "fmt"
    "log"
    "time"
)

// ConnectionState represents connection states
type ConnectionState int

const (
    Disconnected ConnectionState = iota
    Connecting
    Connected
    Reconnecting
    ShuttingDown
)

func (cs ConnectionState) String() string {
    return [...]string{
        "Disconnected",
        "Connecting",
        "Connected",
        "Reconnecting",
        "ShuttingDown",
    }[cs]
}

// Event types
type Event interface {
    String() string
}

type ConnectEvent struct{}

func (ConnectEvent) String() string { return "ConnectEvent" }

type DisconnectEvent struct{}

func (DisconnectEvent) String() string { return "DisconnectEvent" }

type ConnectionSuccessEvent struct{}

func (ConnectionSuccessEvent) String() string { return "ConnectionSuccessEvent" }

type ConnectionFailedEvent struct {
    Err error
}

func (e ConnectionFailedEvent) String() string {
    return fmt.Sprintf("ConnectionFailedEvent: %v", e.Err)
}

type DataReceivedEvent struct {
    Data string
}

func (e DataReceivedEvent) String() string {
    return fmt.Sprintf("DataReceivedEvent: %s", e.Data)
}

type ShutdownEvent struct{}

func (ShutdownEvent) String() string { return "ShutdownEvent" }

// ConnectionManager manages connection state with select-based event loop
type ConnectionManager struct {
    state          ConnectionState
    events         chan Event
    data           chan string
    shutdown       chan struct{}
    reconnectTimer *time.Timer
    ctx            context.Context
    cancel         context.CancelFunc
}

func NewConnectionManager() *ConnectionManager {
    ctx, cancel := context.WithCancel(context.Background())

    return &ConnectionManager{
        state:    Disconnected,
        events:   make(chan Event, 10),
        data:     make(chan string, 10),
        shutdown: make(chan struct{}),
        ctx:      ctx,
        cancel:   cancel,
    }
}

func (cm *ConnectionManager) Start() {
    log.Printf("Connection manager starting in state: %s", cm.state)

    for {
        select {
        case event := <-cm.events:
            cm.handleEvent(event)

        case data := <-cm.data:
            cm.handleData(data)

        case <-cm.reconnectTimer.C:
            if cm.state == Reconnecting {
                log.Println("Reconnect timer fired")
                cm.attemptConnect()
            }

        case <-cm.shutdown:
            log.Println("Shutdown signal received")
            cm.handleShutdown()
            return

        case <-cm.ctx.Done():
            log.Println("Context cancelled")
            return
        }
    }
}

func (cm *ConnectionManager) handleEvent(event Event) {
    log.Printf("[%s] Received event: %s", cm.state, event)

    switch cm.state {
    case Disconnected:
        cm.handleDisconnectedState(event)
    case Connecting:
        cm.handleConnectingState(event)
    case Connected:
        cm.handleConnectedState(event)
    case Reconnecting:
        cm.handleReconnectingState(event)
    case ShuttingDown:
        cm.handleShuttingDownState(event)
    }
}

func (cm *ConnectionManager) handleDisconnectedState(event Event) {
    switch e := event.(type) {
    case ConnectEvent:
        cm.transition(Connecting)
        cm.attemptConnect()

    case ShutdownEvent:
        cm.transition(ShuttingDown)
        close(cm.shutdown)

    default:
        log.Printf("[%s] Ignoring event: %s", cm.state, e)
    }
}

func (cm *ConnectionManager) handleConnectingState(event Event) {
    switch e := event.(type) {
    case ConnectionSuccessEvent:
        cm.transition(Connected)

    case ConnectionFailedEvent:
        log.Printf("Connection failed: %v, will retry", e.Err)
        cm.transition(Reconnecting)
        cm.scheduleReconnect(2 * time.Second)

    case DisconnectEvent:
        cm.transition(Disconnected)

    case ShutdownEvent:
        cm.transition(ShuttingDown)
        close(cm.shutdown)

    default:
        log.Printf("[%s] Ignoring event: %s", cm.state, e)
    }
}

func (cm *ConnectionManager) handleConnectedState(event Event) {
    switch e := event.(type) {
    case DisconnectEvent:
        cm.transition(Disconnected)

    case ConnectionFailedEvent:
        log.Printf("Connection lost: %v, will reconnect", e.Err)
        cm.transition(Reconnecting)
        cm.scheduleReconnect(2 * time.Second)

    case ShutdownEvent:
        cm.transition(ShuttingDown)
        close(cm.shutdown)

    default:
        log.Printf("[%s] Ignoring event: %s", cm.state, e)
    }
}

func (cm *ConnectionManager) handleReconnectingState(event Event) {
    switch e := event.(type) {
    case ConnectionSuccessEvent:
        if cm.reconnectTimer != nil {
            cm.reconnectTimer.Stop()
        }
        cm.transition(Connected)

    case ConnectionFailedEvent:
        log.Printf("Reconnect failed: %v, will retry", e.Err)
        cm.scheduleReconnect(5 * time.Second)

    case DisconnectEvent:
        if cm.reconnectTimer != nil {
            cm.reconnectTimer.Stop()
        }
        cm.transition(Disconnected)

    case ShutdownEvent:
        if cm.reconnectTimer != nil {
            cm.reconnectTimer.Stop()
        }
        cm.transition(ShuttingDown)
        close(cm.shutdown)

    default:
        log.Printf("[%s] Ignoring event: %s", cm.state, e)
    }
}

func (cm *ConnectionManager) handleShuttingDownState(event Event) {
    log.Printf("[%s] Ignoring event during shutdown: %s", cm.state, event)
}

func (cm *ConnectionManager) handleData(data string) {
    if cm.state == Connected {
        log.Printf("[%s] Processing data: %s", cm.state, data)
    } else {
        log.Printf("[%s] Dropping data (not connected): %s", cm.state, data)
    }
}

func (cm *ConnectionManager) handleShutdown() {
    log.Println("Shutting down connection manager")
    if cm.reconnectTimer != nil {
        cm.reconnectTimer.Stop()
    }
    cm.cancel()
}

func (cm *ConnectionManager) transition(newState ConnectionState) {
    log.Printf("State transition: %s -> %s", cm.state, newState)
    cm.state = newState
}

func (cm *ConnectionManager) attemptConnect() {
    log.Println("Attempting to connect...")

    // Simulate async connection attempt
    go func() {
        time.Sleep(time.Duration(500+rand.Intn(1000)) * time.Millisecond)

        // Simulate random success/failure
        if rand.Float32() > 0.3 {
            cm.events <- ConnectionSuccessEvent{}
        } else {
            cm.events <- ConnectionFailedEvent{Err: fmt.Errorf("connection refused")}
        }
    }()
}

func (cm *ConnectionManager) scheduleReconnect(delay time.Duration) {
    log.Printf("Scheduling reconnect in %v", delay)
    if cm.reconnectTimer != nil {
        cm.reconnectTimer.Stop()
    }
    cm.reconnectTimer = time.AfterFunc(delay, func() {
        cm.attemptConnect()
    })
}

// Public API methods

func (cm *ConnectionManager) Connect() {
    cm.events <- ConnectEvent{}
}

func (cm *ConnectionManager) Disconnect() {
    cm.events <- DisconnectEvent{}
}

func (cm *ConnectionManager) SendData(data string) {
    cm.data <- data
}

func (cm *ConnectionManager) Shutdown() {
    cm.events <- ShutdownEvent{}
}

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

    manager := NewConnectionManager()

    // Start the state machine in a goroutine
    go manager.Start()

    // Simulate various events
    time.Sleep(500 * time.Millisecond)
    manager.Connect()

    time.Sleep(2 * time.Second)
    manager.SendData("Hello, World!")

    time.Sleep(1 * time.Second)
    manager.Disconnect()

    time.Sleep(500 * time.Millisecond)
    manager.Connect()

    time.Sleep(3 * time.Second)
    manager.SendData("Test message")

    time.Sleep(2 * time.Second)
    manager.Shutdown()

    time.Sleep(1 * time.Second)
    fmt.Println("\n=== Demo Complete ===")
}
graph LR Disconnected[Disconnected]:::lightBlue Connecting[Connecting]:::lightYellow Connected[Connected]:::lightGreen Reconnecting[Reconnecting]:::lightOrange ShuttingDown[Shutting Down]:::lightPurple Disconnected -->|ConnectEvent| Connecting Connecting -->|ConnectionSuccessEvent| Connected Connecting -->|ConnectionFailedEvent| Reconnecting Connected -->|DisconnectEvent| Disconnected Connected -->|ConnectionFailedEvent| Reconnecting Reconnecting -->|Timer| Connecting Reconnecting -->|ConnectionSuccessEvent| Connected Reconnecting -->|DisconnectEvent| Disconnected Disconnected -->|ShutdownEvent| ShuttingDown Connecting -->|ShutdownEvent| ShuttingDown Connected -->|ShutdownEvent| ShuttingDown Reconnecting -->|ShutdownEvent| ShuttingDown classDef lightBlue fill:#87CEEB,stroke:#4682B4,stroke-width:2px,color:#000 classDef lightGreen fill:#90EE90,stroke:#228B22,stroke-width:2px,color:#000 classDef lightYellow fill:#FFFFE0,stroke:#FFD700,stroke-width:2px,color:#000 classDef lightOrange fill:#FFDAB9,stroke:#FF8C00,stroke-width:2px,color:#000 classDef lightPurple fill:#DDA0DD,stroke:#9370DB,stroke-width:2px,color:#000

Advanced Pattern: Multi-Input State Machine

Here’s a game input handler that processes multiple input sources:

package main

import (
    "context"
    "fmt"
    "time"
)

type InputType int

const (
    KeyPress InputType = iota
    MouseClick
    GamepadButton
)

type Input struct {
    Type  InputType
    Value string
}

type GameState int

const (
    MainMenu GameState = iota
    Playing
    Paused
    GameOver
)

func (gs GameState) String() string {
    return [...]string{"MainMenu", "Playing", "Paused", "GameOver"}[gs]
}

type GameStateMachine struct {
    state          GameState
    keyboardInput  chan Input
    mouseInput     chan Input
    gamepadInput   chan Input
    gameEvents     chan string
    tickRate       *time.Ticker
    ctx            context.Context
    cancel         context.CancelFunc
    score          int
}

func NewGameStateMachine() *GameStateMachine {
    ctx, cancel := context.WithCancel(context.Background())

    return &GameStateMachine{
        state:         MainMenu,
        keyboardInput: make(chan Input, 5),
        mouseInput:    make(chan Input, 5),
        gamepadInput:  make(chan Input, 5),
        gameEvents:    make(chan string, 5),
        tickRate:      time.NewTicker(100 * time.Millisecond),
        ctx:           ctx,
        cancel:        cancel,
    }
}

func (gsm *GameStateMachine) Run() {
    fmt.Printf("Game started in state: %s\n", gsm.state)

    for {
        select {
        case input := <-gsm.keyboardInput:
            gsm.handleKeyboard(input)

        case input := <-gsm.mouseInput:
            gsm.handleMouse(input)

        case input := <-gsm.gamepadInput:
            gsm.handleGamepad(input)

        case event := <-gsm.gameEvents:
            gsm.handleGameEvent(event)

        case <-gsm.tickRate.C:
            gsm.tick()

        case <-gsm.ctx.Done():
            fmt.Println("Game shutting down")
            return
        }
    }
}

func (gsm *GameStateMachine) handleKeyboard(input Input) {
    fmt.Printf("[%s] Keyboard: %s\n", gsm.state, input.Value)

    switch gsm.state {
    case MainMenu:
        if input.Value == "ENTER" {
            gsm.transition(Playing)
        } else if input.Value == "ESC" {
            gsm.shutdown()
        }

    case Playing:
        if input.Value == "ESC" {
            gsm.transition(Paused)
        } else if input.Value == "SPACE" {
            gsm.score += 10
            fmt.Printf("Score: %d\n", gsm.score)
        }

    case Paused:
        if input.Value == "ESC" {
            gsm.transition(Playing)
        } else if input.Value == "Q" {
            gsm.transition(MainMenu)
            gsm.score = 0
        }

    case GameOver:
        if input.Value == "ENTER" {
            gsm.transition(MainMenu)
            gsm.score = 0
        }
    }
}

func (gsm *GameStateMachine) handleMouse(input Input) {
    fmt.Printf("[%s] Mouse: %s\n", gsm.state, input.Value)

    if gsm.state == Playing {
        gsm.score += 5
        fmt.Printf("Score: %d\n", gsm.score)
    }
}

func (gsm *GameStateMachine) handleGamepad(input Input) {
    fmt.Printf("[%s] Gamepad: %s\n", gsm.state, input.Value)

    switch gsm.state {
    case Playing:
        if input.Value == "START" {
            gsm.transition(Paused)
        }

    case Paused:
        if input.Value == "START" {
            gsm.transition(Playing)
        }
    }
}

func (gsm *GameStateMachine) handleGameEvent(event string) {
    fmt.Printf("[%s] Game event: %s\n", gsm.state, event)

    switch event {
    case "PLAYER_DIED":
        if gsm.state == Playing {
            gsm.transition(GameOver)
        }

    case "LEVEL_COMPLETE":
        if gsm.state == Playing {
            gsm.score += 100
            fmt.Printf("Level complete! Score: %d\n", gsm.score)
        }
    }
}

func (gsm *GameStateMachine) tick() {
    if gsm.state == Playing {
        // Game logic tick
        // fmt.Printf("[%s] Tick (Score: %d)\n", gsm.state, gsm.score)
    }
}

func (gsm *GameStateMachine) transition(newState GameState) {
    fmt.Printf("\n>>> State transition: %s -> %s <<<\n\n", gsm.state, newState)
    gsm.state = newState
}

func (gsm *GameStateMachine) shutdown() {
    fmt.Println("Initiating shutdown...")
    gsm.tickRate.Stop()
    gsm.cancel()
}

// Public API
func (gsm *GameStateMachine) SendKeyboard(key string) {
    gsm.keyboardInput <- Input{Type: KeyPress, Value: key}
}

func (gsm *GameStateMachine) SendMouse(button string) {
    gsm.mouseInput <- Input{Type: MouseClick, Value: button}
}

func (gsm *GameStateMachine) SendGamepad(button string) {
    gsm.gamepadInput <- Input{Type: GamepadButton, Value: button}
}

func (gsm *GameStateMachine) SendGameEvent(event string) {
    gsm.gameEvents <- event
}

func ExampleGameStateMachine() {
    game := NewGameStateMachine()

    go game.Run()

    // Simulate game session
    time.Sleep(500 * time.Millisecond)

    fmt.Println("=== Starting Game ===")
    game.SendKeyboard("ENTER")

    time.Sleep(300 * time.Millisecond)
    game.SendKeyboard("SPACE")
    game.SendMouse("LEFT_CLICK")

    time.Sleep(300 * time.Millisecond)
    game.SendKeyboard("ESC") // Pause

    time.Sleep(300 * time.Millisecond)
    game.SendGamepad("START") // Resume

    time.Sleep(300 * time.Millisecond)
    game.SendGameEvent("LEVEL_COMPLETE")

    time.Sleep(300 * time.Millisecond)
    game.SendGameEvent("PLAYER_DIED")

    time.Sleep(300 * time.Millisecond)
    game.SendKeyboard("ENTER") // Back to menu

    time.Sleep(300 * time.Millisecond)
    game.SendKeyboard("ESC") // Quit

    time.Sleep(500 * time.Millisecond)
    fmt.Println("=== Game Session Complete ===")
}

Pattern: Timeout-Based State Transitions

Handle timeouts elegantly with select:

type TimeoutStateMachine struct {
    state   string
    timeout *time.Timer
    events  chan string
}

func (tsm *TimeoutStateMachine) Run() {
    for {
        select {
        case event := <-tsm.events:
            tsm.handleEvent(event)
            // Reset timeout on activity
            tsm.timeout.Reset(30 * time.Second)

        case <-tsm.timeout.C:
            fmt.Println("Timeout! Transitioning to idle")
            tsm.state = "idle"
        }
    }
}

Benefits of Select-Based State Machines

  1. Concurrent Event Handling: Process multiple input channels simultaneously
  2. Non-Blocking: State machine remains responsive
  3. Timeout Support: Built-in timeout handling with time.After
  4. Cancellation: Easy integration with context for graceful shutdown
  5. Priority: Control event priority through select case ordering

When to Use This Pattern

Select-based state machines are ideal when:

  • You need to react to multiple concurrent event sources
  • Timeouts and deadlines are important
  • You want non-blocking state transitions
  • Building network protocols or connection managers
  • Creating responsive game input systems

Comparison: Traditional vs Select-Based

Aspect Traditional Select-Based
Event handling Sequential Concurrent
Blocking Can block Non-blocking
Timeouts Complex Built-in
Cancellation Manual Context-aware
Multiple inputs Difficult Natural

Thank you

Select-based state machines harness Go’s concurrency primitives to create responsive, event-driven systems. By combining channels, select statements, and goroutines, you can build state machines that handle complex concurrent scenarios with elegance.

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