Input
The input package holds the state of every key,
button, stick and pointer, read through ctx.Input. Game code never
polls the platform. The engine feeds events in and clears the edges
after each update, so KeyPressed is true for exactly one update per
press, however many frames the press spans. OS repeats also count as
presses; several events in one update collapse to a single true value.
Keys and the mouse
KeyDown reports whether a key is held; KeyPressed and KeyReleased
report the changes since the last update; KeyRepeated reports the
operating system's auto-repeat, for menus that scroll while a key is
held. KeyHeld is how long a key has been down, for a charged shot, and
KeysDown lists every held key, for combos and rebinding screens.
KeysDown allocates the slice it returns, so it belongs in a settings
screen or a check made on demand; to scan every frame, keep a slice and
pass it to AppendKeysDown.
Chars is the text typed this update with the keyboard layout and
modifiers applied, which is what a text field reads; Composition is
the input method's text in progress.
On Wayland, compositor modifier notifications update Mods independently
of key events, so modifier releases and lock changes are visible without
waiting for another key press.
Keys are named by physical position. KeyW is the key in W's place on a
US keyboard whatever it prints, which is what movement bindings need.
Key.String supplies a stable physical name. For a binding prompt, read
ctx.KeyboardLayout() on the game goroutine when opening or refreshing
the bindings screen:
layout, err := ctx.KeyboardLayout()
label := input.KeyW.String()
if err == nil {
label = layout.Label(input.KeyW) // the current layout's label at W's position
}
The returned value is an independent snapshot. Symbol(key) maps a
physical key to its unmodified logical symbol; KeysFor performs the
reverse lookup and returns all matches in physical-key order:
if layout, err := ctx.KeyboardLayout(); err == nil {
keys := layout.KeysFor(input.TextSymbol("a"))
for _, key := range keys {
if ctx.Input.KeyPressed(key) {
g.selectAll()
}
}
}
Symbols have separate text:, key: and dead: namespaces; for example,
input.TextSymbol("a"), input.KeySymbol("key:Enter") and a backend's dead-key
name. Empty means unknown. Named keys use the engine's names where an
equivalent exists, otherwise a native name. Labels are display text and
should not be stored as binding identities. The snapshot uses the native
layout's level zero with modifiers and locks off. Windows and common XKB
layouts therefore map keypad digits to navigation symbols, while macOS
keypads remain numeric. KeysFor("key:End") can include both End and keypad 1.
Snapshots do not read or change the user's pending dead-key composition or
IME text. Windows translation uses an isolated worker thread; refresh binding
UI when needed instead of querying every frame. X11 and Wayland use the active
XKB group, and macOS uses the current Unicode keyboard layout. Missing keymaps
and headless mode return engine.ErrUnsupported. Label falls back to
Key.String; that fallback does not invent a logical symbol. Continue using
Chars and Composition for typed text, including modifiers and IME input.
func (g *game) Update(ctx *engine.Context) error {
in := ctx.Input
if in.KeyPressed(input.KeySpace) {
g.jump()
}
if in.KeyDown(input.KeyD) {
g.x += speed * float32(ctx.Delta)
}
if t := in.KeyHeld(input.KeyF); t > 0 {
g.charge = min(t, 2) // seconds the key has been down
}
g.held = in.AppendKeysDown(g.held[:0]) // combos, without allocating
for _, k := range g.held {
g.combo = append(g.combo, k.String())
}
g.typed = append(g.typed, in.Chars()...)
return nil
}
Mouse and MousePos give the pointer in view units, MouseDelta its
movement (raw motion when the cursor is captured), Scroll the wheel in
lines, and the Mouse* button methods mirror the key ones.
MouseDoubleClicked reports the second of two quick presses close
together. Capture belongs to the window rather than the input state:
ctx.SetCursorCaptured hides the pointer and delivers relative motion
only, which is what a first-person camera needs.
ctx.SetPointerPosition(x, y) requests an absolute pointer location in
view units, accounting for letterboxing and display scale. It returns an
error; inspect ctx.WindowCapabilities().PointerPosition before offering
this feature. macOS, Windows, and X11 support it. Wayland forbids arbitrary
pointer warping and returns engine.ErrUnsupported. Pointer input reflects
the change after native events are polled, rather than being overwritten
immediately by the request.
in := ctx.Input
p := in.MousePos() // view units, a lin.Vec2
if in.MousePressed(input.MouseLeft) {
g.selectAt(p.X, p.Y)
}
if in.MouseDoubleClicked(input.MouseLeft) {
g.openAt(p.X, p.Y)
}
if _, dy := in.Scroll(); dy != 0 { // in lines; a trackpad's smooth scrolling is scaled to lines
g.zoom *= 1 + dy*0.1
}
if in.KeyPressed(input.KeyC) {
ctx.SetCursorCaptured(!ctx.CursorCaptured())
}
if ctx.CursorCaptured() {
dx, dy := in.MouseDelta() // raw motion, no screen edge to stop at
g.yaw += dx * 0.002
g.pitch += dy * 0.002
}
During Draw the "changed" accessors cover the whole drawn frame, so an
immediate-mode interface built in Draw sees every press even when the
frame ran several updates or none.
Keyboard, mouse and gamepad button transitions are reported once per
drawn frame. Drawing without an intervening update does not repeat an
edge, and drawing does not consume the edge still pending for Update.
Gamepads
Gamepad(i) is the ith controller: Connected, its Name, Down,
Pressed and Released for the standard buttons, and Axis for the
sticks and triggers in -1 to 1. Sticks report up as positive y, as the
hardware does. JustConnected and JustDisconnected mark the update a
controller appears or vanishes, for a join prompt or a pause. These two
connection flags and axis transitions are update-only; only gamepad
button transitions have a separate whole-frame view during Draw. There are
MaxGamepads of them, and Gamepad(i) is never nil, so an unplugged
controller reads as nothing held. The returned pointer is a snapshot;
fetch it again for fresh state. Axis zeroes values strictly between
-0.08 and 0.08 without rescaling the remainder. Action maps use their
own dead zone, 0.2 by default.
pad.Info reports a backend name, a native device name when available,
vendor/product IDs, and masks of controls mapped into the standard gamepad
state. Info.HasButton and Info.HasAxis let binding UI omit unavailable
controls. Zero IDs and false masks mean unavailable or unknown, and disconnected
slots have empty metadata. These values describe a device; they are not a
persistent device identity.
pad := ctx.Input.Gamepad(0)
if pad.Connected && pad.Info.HasAxis(input.AxisRightTrigger) {
g.trigger = pad.Axis(input.AxisRightTrigger)
}
Linux reads the kernel joystick axis/button maps and available sysfs IDs, so physical device indices do not have to follow one fixed order. Unrecognized controls remain unmapped; there is no controller mapping database. Windows uses XInput capability masks and leaves USB IDs unknown. macOS reports the controls exposed by GameController and also leaves USB IDs unknown.
pad := ctx.Input.Gamepad(0)
if pad.JustConnected() {
g.log("player one: " + pad.Name)
}
if pad.Pressed(input.ButtonA) {
g.jump()
}
x, y := pad.Axis(input.AxisLeftX), pad.Axis(input.AxisLeftY)
if x*x+y*y > 0.04 { // a dead zone of 0.2
g.move(x, y) // y is positive up, as the stick reports it
}
g.trigger = pad.Axis(input.AxisRightTrigger) // 0 to 1
for i := range input.MaxGamepads {
if ctx.Input.Gamepad(i).JustDisconnected() {
g.paused = true
}
}
Actions
Actions names what the player does and binds each name to any number
of sources. Game code that reads keys directly cannot be rebound, and it
needs a second copy of every check to support a gamepad.
acts := input.NewActions()
acts.Bind("jump", input.KeySource(input.KeySpace), input.PadButton(input.ButtonA))
acts.Bind("fire", input.MouseSource(input.MouseLeft), input.PadAxis(input.AxisRightTrigger))
acts.Bind("move_x",
input.KeySource(input.KeyD), input.KeySource(input.KeyA).Neg(),
input.PadAxis(input.AxisLeftX), input.PadAxis(input.AxisLeftX).Neg())
if acts.Pressed(ctx.Input, "jump") { ... }
vx := acts.Value(ctx.Input, "move_x") // -1 to 1 from keys or the stick
Down, Pressed and Released work as they do for keys. Value sums
the sources and clamps the result, applying a dead zone to sticks. An
axis source reads one side of a stick, so bind its Neg for the other
side. Pad chooses which controller the pad sources read.
Action handles
The methods above look the name up on every call. A game asks the same
few actions several times per frame, so resolve each one once with
Action and keep the handle:
type game struct {
acts *input.Actions
jump, moveX input.Action
}
func (g *game) Init(ctx *engine.Context) error {
g.acts = input.NewActions()
g.acts.Bind("jump", input.KeySource(input.KeySpace), input.PadButton(input.ButtonA))
g.acts.Bind("move_x",
input.KeySource(input.KeyD), input.KeySource(input.KeyA).Neg(),
input.PadAxis(input.AxisLeftX), input.PadAxis(input.AxisLeftX).Neg())
g.jump = g.acts.Action("jump")
g.moveX = g.acts.Action("move_x")
return nil
}
func (g *game) Update(ctx *engine.Context) error {
if g.jump.Pressed(ctx.Input) { ... }
vx := g.moveX.Value(ctx.Input)
return nil
}
Action is a small value with the same Value, Down, Pressed,
Released and Bindings methods, minus the name argument. A handle
names the action rather than the sources behind it, so it stays valid
across Bind, Rebind, Unbind and loading a settings file, and the
name need not be bound when the handle is taken. The zero Action is
bound to nothing and reads as off.
A settings screen calls Listen each update while it waits for the
player. Listen returns the first key, button or stick flick, and
Rebind swaps it in, replacing every binding the action had. Names
and Bindings fill the rest of the screen.
for _, name := range acts.Names() {
g.row(name, acts.Bindings(name)) // each Source prints as "key:Space"
}
if g.listening != "" { // the player pressed "change" on this action
if src, ok := acts.Listen(ctx.Input); ok {
acts.Rebind(g.listening, src)
g.listening = ""
}
}
The whole map marshals to JSON as {"jump": ["key:J", "pad:A"]} for a
settings file, and ParseSource reads the same names.
data, err := json.Marshal(acts) // {"jump":["key:Space","pad:A"],...}
if err != nil {
return err
}
loaded := input.NewActions()
if err := json.Unmarshal(data, loaded); err != nil {
return err
}
src, err := input.ParseSource("axis:RightTrigger*0.5") // half travel
if err == nil {
loaded.Bind("fire", src)
}