API design
Bunyip keeps the game loop and device lifetime in the engine. Game code describes what to update and draw, owns its application state, and chooses when resources can be released early. Closures handle short scopes; concrete values and typed methods handle the state that survives them.
For the language choices behind this approach, see Why Go?.
Input bindings retain physical input.Key values. For labels and logical
lookup, request a ctx.KeyboardLayout() snapshot when refreshing binding UI:
layout.Label(key) supplies a native label with a physical-name fallback, and
layout.KeysFor(input.TextSymbol("a")) returns every matching physical key.
These snapshots exclude modifiers, locks and pending text composition;
Chars and Composition remain the text-entry APIs. Gamepad snapshots include
Info.HasButton and Info.HasAxis for the controls mapped by the backend.
See Input for platform limits and examples.
Let the engine manage the loop
Run creates the window, graphics, input and audio services and makes
them available through Context. Small programs can capture their state
in callbacks. Every GameFuncs callback is optional:
var elapsed float64
err := engine.Run(engine.Config{Title: "Clock"}, engine.GameFuncs{
UpdateFunc: func(ctx *engine.Context) error {
elapsed += ctx.Delta
return nil
},
DrawFunc: func(ctx *engine.Context) error {
ctx.Gfx.DebugText(20, 20, fmt.Sprintf("%.1f seconds", elapsed))
return nil
},
})
if err != nil {
panic(err)
}
A game type with methods suits larger programs whose state needs names
and structure. Both forms use the same loop. Config.Console also asks
the engine to draw that console after the game and debug overlay.
Give each resource one owner
Graphics owns the GPU resources it creates: textures, fonts, meshes,
models, shaders, render textures and environments. It releases
them when its context closes, including after initialization or drawing
fails. They need no Context.Cleanup registration. Call Destroy when
unloading a level or otherwise releasing a resource early, then stop
using that resource. Graphics handles retirement of work still in flight.
Files, asset loaders and streaming music have their own lifetimes. When
one should live as long as the context, register its cleanup immediately
after acquiring it. Ordinary Go values, such as component structs and
decoded images, need no Close or Destroy.
This setup gives the loader access to the pack until its workers finish:
type game struct {
hero *gfx.Texture
menu *ui.Context
loader *asset.Loader
}
func (g *game) Init(ctx *engine.Context) error {
files, err := asset.Open("assets.pak")
if err != nil {
return err
}
ctx.Cleanup(files.Close)
g.loader = asset.NewLoader(files, 0)
ctx.Cleanup(g.loader.Close)
g.hero, err = asset.Texture(ctx.Gfx, files, "hero.png", gfx.TextureOptions{})
if err != nil {
return err
}
g.menu, err = ctx.NewUI(ui.Theme{})
return err
}
On normal exit, cleanup closes the loader first and the pack second.
Loader.Close finishes accepted jobs and joins its workers before
returning. If texture loading or UI creation fails, those same callbacks
still run; Graphics releases any GPU resources already created.
defer files.Close() inside Init would close the pack when Init
returns. ctx.Cleanup(files.Close) keeps it alive for the context.
Callbacks run in reverse registration order after the game's optional
Shutdown, while graphics and audio are still available. Shutdown
runs only after successful Init or Recover; cleanup also runs when
either setup method fails. Remaining callbacks still run if one panics.
For caller-owned music, ctx.Cleanup(music.Close) follows the same pattern.
Each additional window has its own Graphics and Input. Create its textures, fonts and other GPU resources through that window's context; a texture from one output cannot be drawn by another. Ownership checks reject foreign resources before using their GPU handles. The windows share one audio mixer. Closing a parent closes its children, while closing a child leaves its parent running. Native embedding borrows the host window or view and creates an owned rendering child; keep the host alive until Bunyip finishes teardown. See Windows for the lifecycle and platform restrictions.
Audio output belongs to the mixer, but recording is an explicit acquisition.
Register a recorder's Close with ctx.Cleanup when it should last for the
context, or stop it earlier. Recorder.Stop returns an independent PCM
snapshot. Writer-based recording borrows the writer; RecordWAVFile owns
and closes the file it creates. The default recording limit is 30 seconds,
so forgetting to stop a memory recording cannot grow it indefinitely.
See Audio for capture, completion and error handling.
A reusable TextLayout owns CPU layout data and borrows its font atlases.
It needs no cleanup, and its measurement queries remain usable after font
destruction. Drawing it still requires live fonts from the current Graphics.
This distinction lets durable game data outlive a rendering resource without
pretending that the GPU resource is still available.
Put temporary work inside a closure
A drawing scope makes the affected calls visible and restores the prior state when the callback finishes, including when it panics:
ctx.Gfx.Layered(2, func() {
ctx.Gfx.WithCamera2D(camera, func() {
ctx.Gfx.DrawTexture(g.hero, 100, 80)
})
})
ctx.Gfx.DebugText(20, 20, "Camera and layer restored")
Blended, CustomBlended, Transformed, Shaded, ColorMatrixed and
Clip follow the same pattern. WithView scopes a destination viewport
and virtual size; Masked scopes stencil coverage. DrawTo scopes the
render target. These scopes restore
state; they do not undo drawing already queued.
UI uses closures to keep a frame and its layout together. Create the
interface once in Init, as above, and reuse it in Draw:
g.menu.Begin(ctx.Input, func() {
g.menu.Panel("Options", lin.R(20, 20, 260, 160), func() {
g.menu.Slider("Volume", &volume, 0, 1) // volume is a float32
if g.menu.Button("Quit") {
ctx.Quit()
}
})
})
The zero theme passed to Context.NewUI selects a dark theme and the
engine's shared font, with clipboard and input-method support connected.
A custom theme keeps its settings; a missing font receives the shared
font. The engine owns that font, so do not destroy it separately.
For ECS iteration, a closure gives structural changes a clear application point. Component fields can change during the walk; entity removal waits until the whole walk finishes:
type Lifetime struct{ Left float64 }
world.Defer(func(cmd *ecs.Commands) {
world.Each(func(e ecs.Entity, life *Lifetime) {
life.Left -= ctx.Delta
if life.Left <= 0 {
cmd.Despawn(e)
}
})
})
World.Defer applies its commands in order after a normal return and
discards pending commands on panic. It is not a transaction: direct
component writes and completed nested scopes are not rolled back.
Keep the command buffer inside its callback.
Defaults should preserve intent
Omitting window dimensions selects 1280 by 720; each nonpositive dimension
defaults independently. TextureOptions{} selects nearest filtering for
pixel art, while Linear: true requests smoothing. Defaults are specific
to each API, rather than a rule that every numeric zero means "unset".
Graphics starts with gfx.DefaultPost(). To change selected settings,
edit the current values:
ctx.Gfx.ConfigurePost(func(p *gfx.PostSettings) {
p.Bloom = 0 // disable bloom
p.Saturation = 0 // monochrome
})
Untouched settings retain their values. SetPost replaces the entire
configuration, preserving explicit zeros: zero saturation removes colour
and zero contrast flattens contrast. Use DefaultPost as the starting
value when constructing a complete replacement. Post-processing settings
are global to the submitted frame, including render textures;
ConfigurePost does not create a temporary drawing scope.
Use typed operations and small interfaces
Operations live on the object they use. world.Get[Lifetime](entity)
returns a *Lifetime and a presence flag; world.Each infers the component
type from its callback. Similarly, loader.Load infers its result type
from the decoder. Callers work with concrete results without type assertions.
An interface is useful where implementations really vary. Asset helpers
accept the standard fs.FS, so this function works with embedded assets,
os.DirFS, an asset.FS overlay or a fstest.MapFS in a test:
//go:embed assets
var embedded embed.FS
func loadHero(g *gfx.Graphics, files fs.FS) (*gfx.Texture, error) {
return asset.Texture(g, files, "assets/hero.png", gfx.TextureOptions{})
}
// During Init:
hero, err := loadHero(ctx.Gfx, embedded)
fs.FS does not imply ownership or provide Close. Embedded filesystems
and os.DirFS need no shutdown action. An asset.FS with open packs does.
If you open an individual fs.File, close that handle before its backing
filesystem; helpers such as fs.ReadFile manage their own temporary handles.
Shader parameters follow the same principle: Shader.SetUniforms copies an
ordinary struct and inserts the GPU's required padding. Use exported fields
and the documented scalar, vector and matrix types; unsupported fields return
an error and preserve the previous parameters. Game code describes the
values, while the engine handles their byte layout. See Shaders
for matching Go and shader declarations.
Keep device work on the game goroutine
Use graphics, UI and context operations from the game's callbacks.
Background loaders may read and decode CPU data; create GPU resources
from ready results on the game goroutine. Context.Wake is explicitly
safe to call from another goroutine to request work in an idle loop.
It does not make shared game state safe: synchronize that state yourself.
Device recovery creates a fresh context. A game implementing Recover
must rebuild its GPU resources and UI, restore mixer and console setup,
and register cleanup for new acquisitions. Cleanup for the old context
runs before rebuilding. Keep durable game state separate from device
resources so it can survive this boundary. GameFuncs does not implement
recovery; use a game type with an explicit Recover method when needed.