The debug console
The console package is a drop-down command line over the top of the view and a window of panels that show what every part of the engine is doing: frame timings, GPU resources, the entities of a world, the physics simulation, the mixer, the input devices and whatever services the game attaches. It is meant for the developer and for a player who is helping you find a bug, not for the shipped interface.
Turning it on
Set Config.Console; the engine draws the console last:
func (g *game) Update(ctx *engine.Context) error {
if ctx.Console.Open() {
return nil // the console has the keyboard
}
// ... the game's own key handling ...
return nil
}
func main() {
engine.Run(engine.Config{Title: "Game", Console: true}, &game{})
}
The engine builds the console, attaches what it owns (the graphics
context, the mixer, the input state and the frame timings), tees the log
through it and puts it on Context.Console. It draws the console after
the game's interface and the debug overlay, so no manual Draw call is
needed.
Every console method works on a nil console, so the Open call above can
stay in the game when Config.Console is off and the field is nil.
Config.ConsoleKey chooses the key that opens the drop-down; zero means
the backquote key. A game that wants a console of another shape (a
different height, its own font or theme, scripts read from a pack file)
calls console.New itself and explicitly calls its Draw method last:
g.con = console.New(console.Options{Key: input.KeyF1, Height: 0.5, Read: assets.Read})
Using it
| Key | What it does |
|---|---|
` |
Opens and closes the drop-down |
| F4 | Opens and closes the debug panels |
| F3 | The engine's frame-timing overlay, which is not the console |
| Up, Down | The previous commands |
| Tab | Completes a command or variable name |
| PageUp, PageDown | Scrolls the output |
| Enter | Runs the line |
| Escape | Closes the console |
While the drop-down is open it takes the keyboard: characters go to the
command line, and the game is expected to check Open and leave the
keys alone for that update. Key bindings made with bind fire only
while the console is shut, so they do not go off as you type.
The toggle keys and command line are processed in Draw. An Open
check in Update therefore sees the previous draw's state; the opening
keypress may already have reached gameplay. Open describes the
drop-down only, not the F4 panels. Keep gameplay input routing explicit
when the panels or another interface also need controls.
The built-in commands
help on its own lists every command with its one-line description, and
help <command> describes one.
| Command | What it does |
|---|---|
help [command] |
List the commands, or describe one |
echo <text...> |
Print the arguments |
clear |
Empty the output |
quit |
End the game |
screenshot [file] |
Write the next frame to a PNG, screenshot.png by default |
exec <file> |
Run a file of commands, one per line, skipping blank lines and lines starting with # or // |
bind <key> <command...> |
Run a command when a key is pressed |
unbind <key> |
Drop a key's binding |
binds |
List the key bindings |
fps |
The frame rate and frame time |
stats |
The frame timings, the GPU pass times, the profile scopes and the draw counts |
log <level> |
Show log records at this level and above: debug, info, warn or error |
timescale [x] |
Read or set how fast game time runs |
panels [tab] |
Open or close the debug panels, on a named tab |
set <name> <value> |
Change a registered variable |
get <name> |
Print a registered variable |
vars [prefix] |
List the registered variables |
Key names are the ones input.Key.String gives, so bind F5 drop and
bind Space "echo jump" both work; an argument in double quotes keeps
its spaces.
timescale 0.25 runs game time at a quarter speed without changing the
update rate, so a fast fight can be watched a frame at a time; Delta
is scaled and Time stays real. It reaches the loop through
Context.SetTimeScale, which a game may also call itself.
Commands and variables of your own
Register a command with a name, a one-line description and a function taking the arguments after the name:
ctx.Console.Register("give", "give <item> [count]: add an item to the pack",
func(args []string) (string, error) {
if len(args) == 0 {
return "", fmt.Errorf("give: needs an item")
}
g.inventory.Add(args[0])
return "gave " + args[0], nil
})
What the function returns is printed; the error is printed in red.
Callbacks run synchronously on the goroutine calling Run or Draw,
so avoid blocking work there. Registration is synchronized, but the
game values exposed through variables and attachment callbacks are
still the game's responsibility to synchronize. Normally these are
all read and written on the game loop goroutine.
A variable is a pointer the console reads and writes, which is how a game exposes the numbers it is still tuning without writing a command for each one:
ctx.Console.Float("player.speed", &g.speed, "how fast the player runs")
ctx.Console.Int("world.seed", &g.seed, "")
ctx.Console.Bool("player.noclip", &g.noclip, "walk through walls")
ctx.Console.String("player.name", &g.name, "")
Then set player.speed 8, get player.speed, and vars player. to
list a group. Tab completes variable names after set and get. A
value of another type implements the two-method console.Var interface
and registers with Var.
Registering from Init is the usual place. Device recovery creates a
fresh console and calls the game's Recover method when it implements
engine.Recoverer; it does not call Init again. Put registrations in
shared setup called from both methods. A game without Recoverer
returns the device-loss error instead of recovering.
A file of commands loaded with exec sets a whole scene up at once:
# tuning.cfg
set player.speed 8
set player.noclip true
timescale 0.5
panels Physics
The panels
F4, or the panels command, opens a resizable window of tabs. Drag its
title bar to move it and its bottom-right corner to resize it.
Engine graphs the last few hundred frames, each frame one column
split into the time spent updating, drawing and presenting, with the
line at 16.7 ms that a frame has to stay under to hold sixty a second.
A mark above each column is the time the GPU spent on that frame, drawn
beside the stack rather than inside it because the GPU works on one
frame while the processor records the next, so the two do not add up.
Under it are the frame timings, how many updates the loop ran this frame
and the most it has ever run in one (the fixed-step catch-up), the GPU
time by pass, the profile scopes Context.Profile recorded, the 2D and
3D draw counts with what was culled, and the draw budget from
Config.DrawBudget.
GPU pass times
The engine times each pass on the GPU with timestamp queries: the shadow
atlas, the opaque scene, the screen-space reflections, the blended
scene, the decals, the motion vectors, the temporal resolve, motion
blur, depth of field, the light shafts, bloom, ambient occlusion, the
composite, the anti-aliasing resolve and the 2D stream. A pass that runs for a render
texture as well as for the screen is summed into one figure, and a pass
a frame does not run is left out, so the list says what that frame
actually cost. Work outside the frame, such as a BakeProbe call, is
not timed: it records on its own command buffers and blocks until it is
done, so the wall clock already tells you what it cost. The panel lists them under the graph, the F3 overlay
shows the frame's total as gpu, and a game reads both from
ctx.Stats.GPUFrameMS and ctx.Stats.GPU (gfx.FrameStats.GPUFrameMS
and GPU under it) without the console.
The queries are read back at the frame slot's next begin, once its fence has been waited on, so timing a frame never stalls one: the figures describe a frame a frame or two back and stand still while nothing new lands. A device that cannot time work on its queue, which some MoltenVK configurations are, reports no passes and a zero total rather than failing, and the graph leaves the mark off.
Graphics edits the live PostSettings: exposure, bloom and its
threshold, vignette, saturation, contrast, ambient occlusion and its
radius, the occlusion buffer on its own, and the anti-aliasing pass.
Under those it counts the lights the frame kept and dropped and lists
every GPU resource the graphics context has made and not destroyed:
textures, meshes, models, fonts, render textures and environments, with
their sizes and an estimate of the GPU memory they hold. The same list
is gfx.Graphics.Resources, so a game can watch it without the console.
Entities needs a world attached (see below). It counts the world's entities, systems and resources, lists the entities with the components each carries, and filters them by component name. The list stops at five hundred and says how many matched, so a world of a hundred thousand entities costs no more than that to show. Selecting one shows its components; numbers, bools and strings are edited in place and written back through the world, and everything else is shown as it is. A field being typed into keeps what you type, and every other field follows the live value, so a moving body's position keeps up. The panel also despawns the selected entity, spawns from a prefab library, and lists the systems with their last timings and a switch to turn one off.
Physics counts the bodies (and how many are asleep), the colliders,
the contacts and triggers of the last update, and the joints, for both
dimensions. It edits the phys.Settings2 or phys.Settings3 resource
on the world: gravity, substeps, solver iterations and the sleep
settings. It pauses the world, which turns every system in it off, and
steps it one update at a time. A switch outlines every collider over the
scene as debug lines, contact normals included, which is
phys.DrawColliders3 and phys.DrawColliders2 and can be called
without the console.
Audio lists the buses with a gain slider and mute, solo and pause switches, the playing voices with what they are playing, their gain, pitch, bus and position, and where the listener is. The mixer does not measure its own load, so the panel says so rather than making a number up.
Input shows the keys held, the pointer's position, buttons and scroll, the modifier keys, and every connected gamepad with its buttons and axes. An attached action map is listed with each action's sources and its value this frame.
Services lists the console's own variables with their values, and whatever the game attached: lines of text and network link statistics.
Attaching a game's own things
The engine attaches what it owns. A game attaches the rest, once, with one call each:
func (g *game) Init(ctx *engine.Context) error {
ctx.Console.Attach("world", g.world) // Entities and Physics
ctx.Console.AttachActions("player", g.actions) // Input
ctx.Console.AttachInfo("locale", func() string { return g.tr.Lang() })
ctx.Console.AttachInfo("timers", func() string { return strconv.Itoa(g.timers.Pending()) })
...
}
Attach takes a name, so several worlds can be attached and the panels
show a list to pick from. AttachInfo adds a line to the Services
panel, redrawn from the function every frame: it is how anything the
console knows nothing about gets shown.
Network links go the same way. The console does not depend on the network package, so the game reads the statistics it wants shown:
ctx.Console.AttachLinks("server", func() []console.Link {
var out []console.Link
for _, a := range g.peer.Peers() {
s, _ := g.peer.Stats(a)
out = append(out, console.Link{Peer: a.String(), RTT: s.RTT,
Loss: s.Loss, Pending: s.Pending, Connected: s.Connected})
}
return out
})
A prefab library is a resource on the world rather than an attachment, so the Entities panel finds it on whichever world is showing:
g.world.SetResource(console.Prefabs{"goblin": goblinPrefab, "chest": chestPrefab})
The log
With Config.Console the engine tees the log through the console:
every slog record shows in the drop-down as well as wherever it was
going, coloured by level, and the file Config.LogFile names still gets
its copy. log debug widens what is captured from then on; records
already in the buffer are kept as they are. The console keeps the last
thousand lines by default (Options.Lines).
A console a game builds itself installs the tee where it builds its logger:
con := console.New(console.Options{})
cfg.Log = slog.New(con.Handler(slog.Default().Handler()))
Console.Print and Printf write a line straight into the output, from
any goroutine.
What it costs
A closed console draws nothing and does no work beyond reading the toggle keys and its bindings, one frame's timings for the graph, and any collider drawing that was left on. The engine builds the console whatever the game does with it, so the log tee is in place from the first frame.
The examples are the fastest way to see it: go run ./examples/gallery
and go run ./examples/physics-lab both run with the console on.