The UI Navigation Problem

Game UI often involves stacked screens: you open a pause menu, then settings, then graphics options, then a confirmation dialog. Each screen needs to:

  • Pause the screen beneath it
  • Handle input independently
  • Resume the previous screen when closed
  • Maintain state across transitions

Simple state machines fall short here. You need something that can track a stack of states. Enter the pushdown automaton.

What is a Pushdown Automaton?

A pushdown automaton is a state machine with a stack. Instead of just transitioning between states, you can:

  • Push: Add a new state on top (opening a menu)
  • Pop: Remove the current state and return to the previous one (closing a menu)
  • Replace: Swap the current state (transitioning between game levels)
graph TD subgraph "Stack Evolution" S1[Stack: MainMenu]:::lightBlue S2[Stack: MainMenu → Options]:::lightGreen S3[Stack: MainMenu → Options → Graphics]:::lightYellow S4[Stack: MainMenu → Options]:::lightGreen S5[Stack: MainMenu]:::lightBlue end S1 -->|Push Options| S2 S2 -->|Push Graphics| S3 S3 -->|Pop| S4 S4 -->|Pop| S5 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

Building a UI Stack for Ebitengine

Let’s implement a pushdown automaton for game UI:

package main

import (
    "fmt"
    "image/color"
    "log"

    "github.com/hajimehoshi/ebiten/v2"
    "github.com/hajimehoshi/ebiten/v2/ebitenutil"
    "github.com/hajimehoshi/ebiten/v2/inpututil"
)

// Screen represents a UI screen state
type Screen interface {
    Update() error
    Draw(screen *ebiten.Image)
    OnEnter()
    OnExit()
    OnPause()
    OnResume()
}

// ScreenManager manages a stack of screens
type ScreenManager struct {
    stack []Screen
}

func NewScreenManager() *ScreenManager {
    return &ScreenManager{
        stack: make([]Screen, 0),
    }
}

func (sm *ScreenManager) Push(screen Screen) {
    // Pause current screen if exists
    if len(sm.stack) > 0 {
        sm.stack[len(sm.stack)-1].OnPause()
    }

    sm.stack = append(sm.stack, screen)
    screen.OnEnter()

    log.Printf("Pushed screen (stack depth: %d)", len(sm.stack))
}

func (sm *ScreenManager) Pop() {
    if len(sm.stack) == 0 {
        return
    }

    // Exit current screen
    current := sm.stack[len(sm.stack)-1]
    current.OnExit()

    // Remove from stack
    sm.stack = sm.stack[:len(sm.stack)-1]

    // Resume previous screen if exists
    if len(sm.stack) > 0 {
        sm.stack[len(sm.stack)-1].OnResume()
    }

    log.Printf("Popped screen (stack depth: %d)", len(sm.stack))
}

func (sm *ScreenManager) Replace(screen Screen) {
    if len(sm.stack) > 0 {
        sm.stack[len(sm.stack)-1].OnExit()
        sm.stack[len(sm.stack)-1] = screen
    } else {
        sm.stack = append(sm.stack, screen)
    }

    screen.OnEnter()

    log.Printf("Replaced screen (stack depth: %d)", len(sm.stack))
}

func (sm *ScreenManager) Update() error {
    if len(sm.stack) == 0 {
        return nil
    }

    // Only update the top screen
    return sm.stack[len(sm.stack)-1].Update()
}

func (sm *ScreenManager) Draw(screen *ebiten.Image) {
    if len(sm.stack) == 0 {
        return
    }

    // Draw all screens from bottom to top
    // This allows underlying screens to show through
    for _, s := range sm.stack {
        s.Draw(screen)
    }
}

func (sm *ScreenManager) IsEmpty() bool {
    return len(sm.stack) == 0
}

func (sm *ScreenManager) Current() Screen {
    if len(sm.stack) == 0 {
        return nil
    }
    return sm.stack[len(sm.stack)-1]
}

Implementing Concrete Screens

Now let’s create actual game screens:

// MainMenuScreen - The starting screen
type MainMenuScreen struct {
    manager       *ScreenManager
    selectedIndex int
    menuItems     []string
}

func NewMainMenuScreen(manager *ScreenManager) *MainMenuScreen {
    return &MainMenuScreen{
        manager: manager,
        menuItems: []string{
            "Start Game",
            "Options",
            "Credits",
            "Exit",
        },
    }
}

func (m *MainMenuScreen) Update() error {
    // Handle navigation
    if inpututil.IsKeyJustPressed(ebiten.KeyArrowDown) {
        m.selectedIndex = (m.selectedIndex + 1) % len(m.menuItems)
    }
    if inpututil.IsKeyJustPressed(ebiten.KeyArrowUp) {
        m.selectedIndex = (m.selectedIndex - 1 + len(m.menuItems)) % len(m.menuItems)
    }

    // Handle selection
    if inpututil.IsKeyJustPressed(ebiten.KeyEnter) {
        switch m.selectedIndex {
        case 0: // Start Game
            m.manager.Push(NewGameScreen(m.manager))
        case 1: // Options
            m.manager.Push(NewOptionsScreen(m.manager))
        case 2: // Credits
            m.manager.Push(NewCreditsScreen(m.manager))
        case 3: // Exit
            return fmt.Errorf("game exit requested")
        }
    }

    return nil
}

func (m *MainMenuScreen) Draw(screen *ebiten.Image) {
    screen.Fill(color.RGBA{20, 20, 40, 255})

    ebitenutil.DebugPrintAt(screen, "MAIN MENU", 250, 50)

    for i, item := range m.menuItems {
        y := 150 + i*40
        text := item
        if i == m.selectedIndex {
            text = "> " + text + " <"
        }
        ebitenutil.DebugPrintAt(screen, text, 250, y)
    }

    ebitenutil.DebugPrintAt(screen, "Arrow Keys: Navigate | Enter: Select", 150, 450)
}

func (m *MainMenuScreen) OnEnter() {
    log.Println("MainMenu: OnEnter")
}

func (m *MainMenuScreen) OnExit() {
    log.Println("MainMenu: OnExit")
}

func (m *MainMenuScreen) OnPause() {
    log.Println("MainMenu: OnPause")
}

func (m *MainMenuScreen) OnResume() {
    log.Println("MainMenu: OnResume")
}

// GameScreen - The actual gameplay
type GameScreen struct {
    manager *ScreenManager
    score   int
    paused  bool
}

func NewGameScreen(manager *ScreenManager) *GameScreen {
    return &GameScreen{
        manager: manager,
        score:   0,
    }
}

func (g *GameScreen) Update() error {
    // Open pause menu
    if inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
        g.manager.Push(NewPauseScreen(g.manager))
        return nil
    }

    // Simulate gameplay
    if inpututil.IsKeyJustPressed(ebiten.KeySpace) {
        g.score += 10
    }

    return nil
}

func (g *GameScreen) Draw(screen *ebiten.Image) {
    screen.Fill(color.RGBA{30, 60, 30, 255})

    ebitenutil.DebugPrintAt(screen, "GAMEPLAY", 250, 50)
    ebitenutil.DebugPrintAt(screen, fmt.Sprintf("Score: %d", g.score), 250, 100)
    ebitenutil.DebugPrintAt(screen, "Space: +10 Score | ESC: Pause", 150, 450)
}

func (g *GameScreen) OnEnter() {
    log.Println("GameScreen: OnEnter")
}

func (g *GameScreen) OnExit() {
    log.Println("GameScreen: OnExit")
}

func (g *GameScreen) OnPause() {
    log.Println("GameScreen: OnPause")
    g.paused = true
}

func (g *GameScreen) OnResume() {
    log.Println("GameScreen: OnResume")
    g.paused = false
}

// PauseScreen - Overlays the game
type PauseScreen struct {
    manager       *ScreenManager
    selectedIndex int
    menuItems     []string
}

func NewPauseScreen(manager *ScreenManager) *PauseScreen {
    return &PauseScreen{
        manager: manager,
        menuItems: []string{
            "Resume",
            "Options",
            "Quit to Menu",
        },
    }
}

func (p *PauseScreen) Update() error {
    if inpututil.IsKeyJustPressed(ebiten.KeyArrowDown) {
        p.selectedIndex = (p.selectedIndex + 1) % len(p.menuItems)
    }
    if inpututil.IsKeyJustPressed(ebiten.KeyArrowUp) {
        p.selectedIndex = (p.selectedIndex - 1 + len(p.menuItems)) % len(p.menuItems)
    }

    if inpututil.IsKeyJustPressed(ebiten.KeyEnter) {
        switch p.selectedIndex {
        case 0: // Resume
            p.manager.Pop()
        case 1: // Options
            p.manager.Push(NewOptionsScreen(p.manager))
        case 2: // Quit to Menu
            // Pop both pause and game screens
            p.manager.Pop()
            p.manager.Pop()
        }
    }

    if inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
        p.manager.Pop()
    }

    return nil
}

func (p *PauseScreen) Draw(screen *ebiten.Image) {
    // Draw semi-transparent overlay
    overlay := ebiten.NewImage(screen.Bounds().Dx(), screen.Bounds().Dy())
    overlay.Fill(color.RGBA{0, 0, 0, 180})
    screen.DrawImage(overlay, nil)

    ebitenutil.DebugPrintAt(screen, "PAUSED", 280, 100)

    for i, item := range p.menuItems {
        y := 200 + i*40
        text := item
        if i == p.selectedIndex {
            text = "> " + text + " <"
        }
        ebitenutil.DebugPrintAt(screen, text, 250, y)
    }

    ebitenutil.DebugPrintAt(screen, "Arrow Keys: Navigate | Enter: Select | ESC: Resume", 100, 450)
}

func (p *PauseScreen) OnEnter() {
    log.Println("PauseScreen: OnEnter")
}

func (p *PauseScreen) OnExit() {
    log.Println("PauseScreen: OnExit")
}

func (p *PauseScreen) OnPause() {
    log.Println("PauseScreen: OnPause")
}

func (p *PauseScreen) OnResume() {
    log.Println("PauseScreen: OnResume")
}

// OptionsScreen - Settings menu
type OptionsScreen struct {
    manager       *ScreenManager
    selectedIndex int
    settings      map[string]bool
    menuItems     []string
}

func NewOptionsScreen(manager *ScreenManager) *OptionsScreen {
    return &OptionsScreen{
        manager: manager,
        settings: map[string]bool{
            "Sound":      true,
            "Music":      true,
            "Fullscreen": false,
        },
        menuItems: []string{"Sound", "Music", "Fullscreen", "Back"},
    }
}

func (o *OptionsScreen) Update() error {
    if inpututil.IsKeyJustPressed(ebiten.KeyArrowDown) {
        o.selectedIndex = (o.selectedIndex + 1) % len(o.menuItems)
    }
    if inpututil.IsKeyJustPressed(ebiten.KeyArrowUp) {
        o.selectedIndex = (o.selectedIndex - 1 + len(o.menuItems)) % len(o.menuItems)
    }

    if inpututil.IsKeyJustPressed(ebiten.KeyEnter) {
        if o.selectedIndex == len(o.menuItems)-1 {
            // Back
            o.manager.Pop()
        } else {
            // Toggle setting
            key := o.menuItems[o.selectedIndex]
            o.settings[key] = !o.settings[key]
        }
    }

    if inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
        o.manager.Pop()
    }

    return nil
}

func (o *OptionsScreen) Draw(screen *ebiten.Image) {
    screen.Fill(color.RGBA{40, 40, 60, 255})

    ebitenutil.DebugPrintAt(screen, "OPTIONS", 270, 50)

    for i, item := range o.menuItems {
        y := 150 + i*40
        text := item

        if i < len(o.menuItems)-1 {
            // Show toggle state
            state := "OFF"
            if o.settings[item] {
                state = "ON"
            }
            text = fmt.Sprintf("%s: %s", item, state)
        }

        if i == o.selectedIndex {
            text = "> " + text + " <"
        }

        ebitenutil.DebugPrintAt(screen, text, 200, y)
    }

    ebitenutil.DebugPrintAt(screen, "Arrow Keys: Navigate | Enter: Toggle/Back | ESC: Back", 80, 450)
}

func (o *OptionsScreen) OnEnter() {
    log.Println("OptionsScreen: OnEnter")
}

func (o *OptionsScreen) OnExit() {
    log.Println("OptionsScreen: OnExit")
}

func (o *OptionsScreen) OnPause() {
    log.Println("OptionsScreen: OnPause")
}

func (o *OptionsScreen) OnResume() {
    log.Println("OptionsScreen: OnResume")
}

// CreditsScreen - Simple overlay screen
type CreditsScreen struct {
    manager *ScreenManager
}

func NewCreditsScreen(manager *ScreenManager) *CreditsScreen {
    return &CreditsScreen{manager: manager}
}

func (c *CreditsScreen) Update() error {
    if inpututil.IsKeyJustPressed(ebiten.KeyEscape) || inpututil.IsKeyJustPressed(ebiten.KeyEnter) {
        c.manager.Pop()
    }
    return nil
}

func (c *CreditsScreen) Draw(screen *ebiten.Image) {
    screen.Fill(color.RGBA{20, 20, 30, 255})

    ebitenutil.DebugPrintAt(screen, "CREDITS", 270, 100)
    ebitenutil.DebugPrintAt(screen, "Game Developer: Your Name", 180, 200)
    ebitenutil.DebugPrintAt(screen, "Engine: Ebitengine", 210, 240)
    ebitenutil.DebugPrintAt(screen, "Thanks for playing!", 200, 300)
    ebitenutil.DebugPrintAt(screen, "ESC or Enter: Back", 210, 450)
}

func (c *CreditsScreen) OnEnter() {
    log.Println("CreditsScreen: OnEnter")
}

func (c *CreditsScreen) OnExit() {
    log.Println("CreditsScreen: OnExit")
}

func (c *CreditsScreen) OnPause() {
    log.Println("CreditsScreen: OnPause")
}

func (c *CreditsScreen) OnResume() {
    log.Println("CreditsScreen: OnResume")
}

The Game Loop

Finally, let’s wire everything together:

// Game represents the main game
type Game struct {
    screenManager *ScreenManager
}

func NewGame() *Game {
    manager := NewScreenManager()

    // Start with main menu
    manager.Push(NewMainMenuScreen(manager))

    return &Game{
        screenManager: manager,
    }
}

func (g *Game) Update() error {
    if g.screenManager.IsEmpty() {
        return fmt.Errorf("no screens remaining")
    }

    return g.screenManager.Update()
}

func (g *Game) Draw(screen *ebiten.Image) {
    g.screenManager.Draw(screen)
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
    return 640, 480
}

func main() {
    ebiten.SetWindowSize(640, 480)
    ebiten.SetWindowTitle("Pushdown Automaton UI Demo")

    game := NewGame()

    if err := ebiten.RunGame(game); err != nil {
        log.Fatal(err)
    }
}

Advanced: Transition Effects

Add smooth transitions between screens:

type TransitionScreen struct {
    Screen
    alpha     float64
    fadeIn    bool
    fadeSpeed float64
}

func (t *TransitionScreen) Update() error {
    if t.fadeIn {
        t.alpha += t.fadeSpeed
        if t.alpha >= 1.0 {
            t.alpha = 1.0
            t.fadeIn = false
        }
    }

    return t.Screen.Update()
}

func (t *TransitionScreen) Draw(screen *ebiten.Image) {
    t.Screen.Draw(screen)

    if t.alpha < 1.0 {
        // Apply fade effect
        overlay := ebiten.NewImage(screen.Bounds().Dx(), screen.Bounds().Dy())
        overlay.Fill(color.RGBA{0, 0, 0, uint8((1.0 - t.alpha) * 255)})
        screen.DrawImage(overlay, nil)
    }
}

Benefits of Pushdown Automaton UI

  1. Natural Navigation: Matches how users think about UI (going “back”)
  2. State Preservation: Previous screens maintain their state
  3. Composability: Screens can be reused in different contexts
  4. Clear Lifecycle: OnEnter/OnExit/OnPause/OnResume hooks
  5. Simple Implementation: Just a stack and a few methods

When to Use This Pattern

Pushdown automaton UI is ideal for:

  • Game menus with nested screens
  • Modal dialogs and popups
  • Pause menus that overlay gameplay
  • Settings screens
  • Tutorial overlays

Stack Operation Patterns

// Replace - Use for transitions between game states
manager.Replace(NewLevelTwoScreen(manager))

// Push - Use for overlays and temporary screens
manager.Push(NewDialogScreen(manager, "Are you sure?"))

// Pop - Use to return to previous screen
manager.Pop()

// Clear and Push - Use for complete resets
for !manager.IsEmpty() {
    manager.Pop()
}
manager.Push(NewMainMenuScreen(manager))

Thank you

Pushdown automata provide a clean, intuitive model for managing game UI navigation. By treating your UI as a stack of screens, you get natural back-button behavior, state preservation, and composable screen components. Combined with Ebitengine’s simplicity, you can build polished game UIs with minimal code.

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