Dev Thicket

Building Game UI in Go with Willow UI

Buttons, sliders, reactive bindings, and XML templates. How to build real game menus and HUDs in Go with Willow UI.

If you have built games in Unity or Godot, you know what it feels like to have a real UI toolkit at your fingertips. You drag in a slider, bind it to a value, hit play, and it works. Buttons, text fields, health bars, settings menus; they are all right there, styled and functional out of the box. In Go, that story has been very different. Ebitengine gives you a fantastic foundation for 2D rendering, input, and audio, but it intentionally stops short of UI. You get pixels and a game loop. Everything else is up to you.

Willow UI is built to fill that gap. It is a component library that sits on top of Willow's scene graph and provides the widgets, layout system, reactive data model, and theming that game UI demands. This article walks through what it looks like to build real game interfaces with it.

The challenge of game UI

Game UI is not web UI. A web page can re-layout the entire document every time something changes because the browser is optimized for exactly that. A game cannot. Your UI has to render inside a game loop running at 60 frames per second, sharing a draw budget with sprites, tilemaps, particles, and everything else on screen. Every widget needs to participate in that same render pipeline without fighting it.

Input is different too. Web forms assume a mouse and keyboard. Game UI needs to handle gamepad navigation, analog stick scrolling, and focus rings that make sense when a player is sitting on a couch with a controller. You need directional focus traversal, not tab order.

You also cannot reach for HTML and CSS. There is no DOM, no flexbox, no border-radius. Everything has to be drawn with images and geometry on a GPU surface. That means your UI framework needs its own layout engine, its own text rendering, its own hit testing, and its own styling system.

Most Go developers who have built games with Ebitengine end up writing their own buttons and sliders from scratch. It works for a prototype, but it does not scale. The moment you need a scrollable list, a dropdown, or a settings screen with twenty options, ad-hoc UI code becomes a maintenance problem. That is the problem Willow UI solves.

What Willow UI provides

Willow UI ships a full component library designed specifically for game interfaces. Here is what is included:

  • Buttons and IconButtons with variants, states, and click handlers
  • Text inputs with selection, clipboard support, and password masking
  • Sliders (horizontal and vertical) with reactive value binding
  • Toggles and Checkboxes for boolean settings
  • Tabs with scrollable overflow for category-heavy menus
  • Panels, Accordions, and Nav Drawers for structure
  • Lists and Data Tables with sorting and selection
  • Color Pickers, Calendar Selectors, and Time Pickers
  • Popovers, Tooltips, and Toasts for overlays
  • Toolbars and Menu Bars for editor-style interfaces

Every widget supports reactive data binding through Ref[T] and Computed[T]. You can build your UI in pure Go code, or use XML templates with hot reload during development. A cascading theme system lets you skin everything from a single struct or load themes from JSON.

Building a settings menu

Let's build something real: a game settings screen with a volume slider, a resolution dropdown, a fullscreen toggle, and save/cancel buttons. Here is what that looks like in Go with Willow UI:

func (c *SettingsController) OnCreate(screen *ui.Screen) {
    // Reactive state
    c.volume = ui.NewRef(75.0)
    c.fullscreen = ui.NewRef(false)
    c.resolution = ui.NewRef("1920x1080")

    root := ui.NewVBox("settings")
    root.Spacing = 16
    root.Padding = ui.Insets{Top: 32, Right: 32, Bottom: 32, Left: 32}

    // Volume slider
    volRow := ui.NewHBox("vol-row")
    volRow.Spacing = 12
    volRow.Align = ui.AlignCenter
    volRow.AddChild(ui.NewLabel("vol-lbl", "Volume", c.font, 14))
    volSlider := ui.NewSlider("vol-slider")
    volSlider.SetRange(0, 100)
    volSlider.BindValue(c.volume)
    volSlider.SetSize(200, 20)
    volRow.AddChild(volSlider)
    volLabel := ui.NewLabel("vol-val", "", c.font, 14)
    volLabel.BindText(ui.BindFormatterf(c.volume, "%.0f%%"))
    volRow.AddChild(volLabel)
    root.AddChild(volRow)

    // Fullscreen toggle
    fsRow := ui.NewHBox("fs-row")
    fsRow.Spacing = 12
    fsRow.Align = ui.AlignCenter
    fsRow.AddChild(ui.NewLabel("fs-lbl", "Fullscreen", c.font, 14))
    fsToggle := ui.NewToggle("fs-toggle")
    fsToggle.BindValue(c.fullscreen)
    fsRow.AddChild(fsToggle)
    root.AddChild(fsRow)

    // Save and cancel buttons
    btnRow := ui.NewHBox("btn-row")
    btnRow.Spacing = 12
    saveBtn := ui.NewButton("save", "Save", c.font, 14)
    saveBtn.SetVariant(ui.Primary)
    saveBtn.SetOnClick(func() {
        c.applySettings()
        c.stage.Remove(screen)
    })
    cancelBtn := ui.NewButton("cancel", "Cancel", c.font, 14)
    cancelBtn.SetOnClick(func() {
        c.stage.Remove(screen)
    })
    btnRow.AddChild(saveBtn)
    btnRow.AddChild(cancelBtn)
    root.AddChild(btnRow)

    screen.Add(root)
}

This is a complete, working settings screen. The volume slider is two-way bound to c.volume, so dragging the slider updates the ref, and changing the ref from code moves the slider thumb. The label next to the slider updates automatically through BindFormatterf. The fullscreen toggle works the same way with a boolean ref. The save button applies the settings and pops the screen off the stage stack.

Notice how everything composes through AddChild. Layout containers like HBox and VBox handle positioning. You never set pixel coordinates manually.

Reactive bindings

The reactive system is central to how Willow UI works. Ref[T] holds a single value of any type and notifies watchers when it changes. Computed[T] derives a value from one or more refs and recomputes automatically when its dependencies update.

Here is a practical example: a health bar that stays in sync with a player's HP without any manual update calls.

// In your game state
playerHP := ui.NewRef(100.0)
maxHP := ui.NewRef(100.0)

// Computed ratio for the health bar fill
hpRatio := ui.NewComputed(func() float64 {
    return playerHP.Get() / maxHP.Get()
})

// Build the health bar
bar := ui.NewMeterBar("hp-bar")
bar.BindValue(hpRatio)
bar.SetSize(200, 20)

label := ui.NewLabel("hp-label", "", font, 12)
label.BindText(ui.BindFormatterf(playerHP, "%.0f HP"))

// Later, when the player takes damage:
playerHP.Set(playerHP.Get() - 25)
// The bar and label update automatically. No manual refresh needed.

If you have used React, this will feel familiar. Ref[T] is similar to useState, and Computed[T] is similar to useMemo with automatic dependency tracking. The key difference is that Willow UI's reactive system is synchronous and frame-aligned. Changes propagate within the same game tick, so you never see a stale frame.

You can also watch refs directly for side effects:

ui.WatchValue(playerHP, func(old, new float64) {
    if new < 20 {
        hpBar.SetVariant(ui.Danger) // turn red
    }
})

XML templates

Writing UI in Go code is powerful, but for layout-heavy screens it can get verbose. Willow UI supports XML templates as an alternative. Here is the same settings menu from earlier, expressed as a template:

<Screen name="settings">
  <VBox spacing="16" padding="32">

    <HBox name="vol-row" spacing="12" align="center">
      <Label text="Volume" font="font" fontSize="14" />
      <Slider name="vol-slider"
        min="0" max="100" width="200" height="20"
        bind:value="volume" />
      <Label bind:text="formatf(volume, '%.0f%%')"
        font="font" fontSize="14" />
    </HBox>

    <HBox name="fs-row" spacing="12" align="center">
      <Label text="Fullscreen" font="font" fontSize="14" />
      <Toggle bind:value="fullscreen" />
    </HBox>

    <HBox name="btn-row" spacing="12">
      <Button text="Save" variant="primary"
        on:click="HandleSave" font="font" fontSize="14" />
      <Button text="Cancel"
        on:click="HandleCancel" font="font" fontSize="14" />
    </HBox>

  </VBox>
</Screen>

The template references the same reactive refs (volume, fullscreen) from the controller struct. Bindings like bind:value create two-way connections. Event handlers like on:click map to methods on the controller.

During development, XML templates support hot reload. Edit the file, save, and see the change in your running game without restarting. For production builds, templates compile to binary data embedded in your executable, so there is no file I/O or XML parsing at runtime.

Templates also support conditional rendering with ui:show:

<Label text="Low HP!" ui:show="isLowHP"
  font="font" fontSize="14" />

Where isLowHP is a Ref[bool] or Computed[bool] on the controller. The label appears and disappears reactively.

Theming

Every widget in Willow UI draws its appearance from a Theme. The theme is a single struct that controls colors, borders, padding, corner radii, and font sizes across every widget type. Changing one value updates the entire UI.

theme := ui.DefaultTheme
theme.PrimaryColor = color.RGBA{R: 0x4D, G: 0xB8, B: 0xC3, A: 0xFF}
theme.BackgroundColor = color.RGBA{R: 0x1A, G: 0x1A, B: 0x2E, A: 0xFF}
theme.CornerRadius = 6
theme.ButtonPadding = ui.Insets{Top: 8, Right: 16, Bottom: 8, Left: 16}

stage.SetTheme(theme)

Themes support variants. A Primary button uses the primary color. A Danger button uses red. You define the palette once, and every widget respects it through visual states (normal, hover, pressed, disabled, focused).

For games that need a completely custom look, Willow UI supports nine-slice image backgrounds. Instead of drawing flat colored rectangles, you can assign a nine-slice image to any widget. The corners stay fixed, the edges stretch, and the center tiles. This is how you get ornate fantasy frames, sci-fi panel borders, or hand-painted button skins without writing a custom renderer.

Per-widget overrides are also available when you need one specific button to look different from the rest:

dangerBtn := ui.NewButton("delete", "Delete Save", font, 14)
dangerBtn.SetVariant(ui.Danger)

// Or override with a custom theme for one widget
custom := ui.DefaultTheme
custom.PrimaryColor = color.RGBA{R: 0xCC, G: 0x33, B: 0x33, A: 0xFF}
custom.CornerRadius = 4
dangerBtn.SetTheme(&custom)

Themes can also be loaded from JSON files, which means designers can tweak colors and spacing without touching Go code. The theme compiler bakes JSON themes into binary format for production builds, just like XML templates.

What's next

Willow UI is currently in active development and not yet released. The widget library, reactive system, XML templates, and theming are all functional internally, but the public API is still being finalized. We want to get it right before putting it in your hands.

Here is how to stay in the loop:

  • Visit the Willow UI project page for status updates and release announcements.
  • Browse the documentation to see the full widget catalog, API reference, and more code examples.
  • Join the Dev Thicket Discord to ask questions, share feedback, and follow development progress.

If you are building a game in Go and have been hand-rolling your own UI widgets, Willow UI is designed for exactly your situation. Real widgets. Reactive state. Proper layout. All running inside your Ebitengine game loop at full frame rate.