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)
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
- Natural Navigation: Matches how users think about UI (going “back”)
- State Preservation: Previous screens maintain their state
- Composability: Screens can be reused in different contexts
- Clear Lifecycle: OnEnter/OnExit/OnPause/OnResume hooks
- 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!