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