Bunyip a game engine in Go GitHub

Getting started

Requirements

Bunyip needs Go 1.27 or later and a Vulkan driver. There is nothing to compile against. The engine opens the driver at run time.

Set CGO_ENABLED=0 for all Go commands in this guide. On macOS or Linux, run export CGO_ENABLED=0; in PowerShell, use $env:CGO_ENABLED = "0".

Platform Install
macOS brew install vulkan-loader molten-vk. For validation messages during development, also brew install vulkan-validationlayers.
Linux The GPU vendor's Vulkan driver and Vulkan loader. Native Wayland needs libwayland-client and libxkbcommon; X11 needs libxcb, with libxkbcommon and libxkbcommon-x11 for text input. Audio uses ALSA (libasound).
Windows A current GPU driver; vulkan-1.dll ships with it.

The engine has been tested on macOS and Linux. Linux windowing has run on both native Wayland and X11, and Linux audio output and capture have hardware verification. Windows, Linux gamepads and macOS capture have build and test coverage but still need hardware verification.

A window

Create a module with a main.go:

package main

import (
	"github.com/matjam/bunyip/engine"
	"github.com/matjam/bunyip/gfx"
	"github.com/matjam/bunyip/input"
)

type game struct{}

func (g *game) Update(ctx *engine.Context) error {
	if ctx.Input.KeyPressed(input.KeyEscape) {
		ctx.Quit()
	}
	return nil
}

func (g *game) Draw(ctx *engine.Context) error {
	ctx.Gfx.FillRect(100, 100, 200, 120, gfx.RGB(120, 190, 255))
	return nil
}

func main() {
	if err := engine.Run(engine.Config{Title: "First window", Width: 960, Height: 600, Resizable: true}, &game{}); err != nil {
		panic(err)
	}
}
go mod init example.com/first
go get github.com/matjam/bunyip/engine
go run .

Run opens the window, creates the renderer and the audio device, calls the game's Init method if it has one, and runs the loop until Quit. Graphics owns its textures, fonts, meshes and other GPU resources and releases them at shutdown, including after setup or drawing fails. Use Destroy to release one earlier, for example when unloading a level. For other cleanup, register a closure with ctx.Cleanup after acquiring the resource. The callbacks run in reverse order, after optional Shutdown and before the engine closes its devices; they also run if Init or Recover fails. To recover from a lost graphics device, implement Recover(ctx *engine.Context) error and recreate resources there. The engine calls Recover with a fresh context instead of Init; without that method, Run returns the device-loss error. The mixer and console are also rebuilt, so restore audio configuration, playback and console registrations in Recover. Shutdown is called only after successful Init or Recover.

Small programs can provide closures with GameFuncs instead of declaring a game type. Unset callbacks do nothing:

err := engine.Run(engine.Config{Title: "Hello"}, engine.GameFuncs{
	DrawFunc: func(ctx *engine.Context) error {
		ctx.Gfx.DebugText(20, 20, "Hello, Bunyip")
		return nil
	},
})
if err != nil {
	panic(err)
}

Window width and height default independently to 1280 and 720 points, so Config{Width: 960} keeps that width and uses the default height.

The loop

Real-time is the default mode. Update runs at a fixed step, 60 Hz unless Config.FixedStep sets another value, and Draw runs once per displayed frame. ctx.Delta is the step, so the simulation is the same whatever the frame rate. When frames come faster than updates, ctx.Alpha during Draw holds how far the clock has run past the last update, as a fraction of a step. Draw a body at previous + (current - previous) * Alpha to keep motion smooth.

After a stall (a long load, a window drag) the loop catches up with extra updates. Config.MaxCatchUp caps how much lost time it makes up and Config.MaxSteps caps the updates in one frame; the rest of the time is dropped rather than simulated. Config.PauseUnfocused stops updates and silences the mixer while another window has focus.

ctx.SetTimeScale changes how fast game time runs without changing the update rate: ctx.SetTimeScale(0.25) scales ctx.Delta to a quarter, so the simulation crawls and can be watched, and 0 freezes it. ctx.Time stays real time. The console's timescale command sets it.

Turn-based games set Config.TurnBased. The loop draws the first frame without an Update, then blocks in the operating system until input arrives and runs one Update and one Draw per batch of events while active. The main loop blocks between events; audio and game-owned goroutines can still run. A timer, a network message or a finished asset load can wake it with ctx.Wake. Call ctx.RequestRedraw to ask for another frame while an animation is playing. Controller input does not wake the operating system's wait, so while a controller is connected the loop checks it every 10 ms and runs a turn when a button or stick changes.

The window

Config sizes the window at the start. Context controls it while the game runs: the title, the icon, size limits, the pointer's shape and visibility, full screen, the window's place on the screen, and a fixed view that the engine scales and letterboxes for you. The window guide covers all of it, along with what a resize does to coordinates, which controls each platform supports, and what a headless run gives you instead.

Input

ctx.Input reports what is held (KeyDown), what changed this update (KeyPressed, KeyReleased, MousePressed), the pointer, the wheel, typed text and gamepads. During Draw the "changed" accessors cover the whole frame rather than the last update, so an interface built in Draw sees a click even when several updates ran before it. The input guide covers the rest, including action maps for rebinding.

Debugging

F3 toggles an overlay with the frame time, the update and draw times, draw-call counts and any profile scopes the game recorded; Config.Debug shows it from the start. Its figures change four times a second so they can be read; ctx.Stats still holds every frame's.

ctx.Profile times a section of game code. It returns a scope, and End closes it and records how long it took, into ctx.Stats.Scopes and the overlay:

pathing := ctx.Profile("pathfinding")
g.findPaths()
pathing.End()

// Or for a whole function:
defer ctx.Profile("simulate").End()

The scope is a small value rather than a closure, so it allocates nothing and a section that runs many times a frame can be timed. Timing runs whether or not the overlay is shown.

Config.Console turns on the in-game console: a command line on the backquote key and panels on F4 that show the frame timings, the GPU resources, a world's entities, the physics simulation, the mixer and the input devices, and that let a game expose its own commands and tunable variables. The engine draws it after the game and the debug overlay. The console guide covers it.

Config.DrawBudget turns the draw-call count into a warning when a frame exceeds it. Config.Pprof serves Go's profiler on an address. Config.LogFile writes the log to a file and appends a stack trace if the game panics. That file is the one to ask a player for. engine.FlyCamera is a free-flying camera for looking round a 3D scene while it is being built.

Vulkan validation

To check the game's Vulkan use, turn on the Khronos validation layer with Config.Validation, or set the environment variable BUNYIP_VALIDATION=1 to turn it on without a code change. The layer checks every Vulkan call and logs what it finds through the engine's log. It needs the validation layers installed (on macOS, brew install vulkan-validationlayers); when they are missing the engine logs a warning and runs without them.

BUNYIP_VALIDATION=1 go run ./examples/lighting

The layer adds CPU time to every frame: in the lighting example it raises the submit time from 0.44 to 1.74 ms. Leave it off when measuring performance. The examples leave Config.Validation off for that reason, so go run ./examples/<name> measures the engine; the examples test sets BUNYIP_VALIDATION=1 for every example it runs.

Profiling

Enable Go's profiling server when starting the game:

err := engine.Run(engine.Config{
	Title: "My Game",
	Debug: true,
	Pprof: "127.0.0.1:6060",
}, &game{})
if err != nil {
	panic(err)
}

An empty Pprof skips starting the server. Debug independently enables the frame overlay. Use the loopback address above for local debugging; the profiling server has no authentication. Listener errors are logged without stopping the game. The listener stays open until the process exits, including after Run returns, and is retained during device recovery.

With the game running, reproduce the slow scene while collecting 30 seconds of CPU samples from another terminal:

CGO_ENABLED=0 go tool pprof 'http://127.0.0.1:6060/debug/pprof/profile?seconds=30'

At the pprof prompt, top shows the largest costs and top -cum includes time in called functions. Inspect memory separately:

CGO_ENABLED=0 go tool pprof 'http://127.0.0.1:6060/debug/pprof/heap'
CGO_ENABLED=0 go tool pprof -alloc_space 'http://127.0.0.1:6060/debug/pprof/allocs'

The heap view defaults to sampled live bytes; alloc_space shows cumulative allocated bytes, which helps find temporary allocation churn. For scheduling, blocking and GC activity, collect an execution trace:

curl -o trace.out 'http://127.0.0.1:6060/debug/pprof/trace?seconds=5'
CGO_ENABLED=0 go tool trace trace.out

CPU recording and tracing run on request. Profiling adds overhead, so compare the same workload before and after a change. These tools inspect Go runtime and CPU activity; they do not profile GPU shaders. The standard HTTP profiling documentation describes the endpoints and additional profile types.

The examples

Most examples accept -seconds N to exit after N seconds and -shot file.png to save a screenshot partway through. The headless harness excludes window, network and clear; assets runs with a nonblank check but no golden comparison. Config.Headless (or the environment variable BUNYIP_HEADLESS=1) runs a game with no window, rendering offscreen, so the same screenshots come out of a build machine. A few to try:

go run ./examples/gallery -skin -theme nord
go run ./examples/tiles
go run ./examples/lighting
go run ./examples/terrain
go run ./examples/tetris
go run ./cmd/bunyip-info

Shipping

bunyip-pack bundles an asset directory into one file that the asset package reads alongside loose files. bunyip-bundle produces a macOS .app carrying the Vulkan loader and MoltenVK, or a plain folder on other systems:

go build -o mygame .
go run github.com/matjam/bunyip/cmd/bunyip-bundle -name "My Game" -exe ./mygame -assets ./assets -o dist