Input¶
Games in KKE never ask "is W down?". They ask about actions: "was
jump pressed?", "how much move?". Each action has default keys and
buttons, and players can rebind all of them in the settings, controllers
and flight sticks included. The whole system is described in
Input; these recipes are the parts you use every day.
Your own action: hold to charge, release to throw¶
input.define(id, label, key) makes an action with a default key. Hold
T to charge, let go to throw a ball where the camera looks: harder
(and redder) the longer you held it.
-- throw.lua: your own controls. Hold T to charge, let go to throw; the
-- longer you hold, the harder it flies. Players can rebind T in the
-- settings like any other action. (docs/cookbook/input.md)
-- define(id, label, default key): once per action. The label is what
-- the rebinding screen shows.
input.define("throw", "Throw a ball (hold to charge)", "T")
local charging = false
local charge = 0 -- seconds held
local MAX_CHARGE = 1.5
hook.Add("Think", "throw.update", function(dt)
if input.held("throw") then
-- Held: build up charge while the key is down.
charging = true
charge = math.min(charge + dt, MAX_CHARGE)
elseif charging then
-- Not held any more, but it was last frame: it was just released.
charging = false
local power = 4 + 16 * (charge / MAX_CHARGE) -- 4 to 20 m/s
local from = camera.position() + camera.forward() * 1.5
physics.sphere {
pos = from,
radius = 0.18,
density = 800,
velocity = camera.forward() * power + Vec(0, 2, 0),
color = Vec(1, 1 - charge / MAX_CHARGE, 0.2), -- yellow to red with charge
}
print(string.format("Thrown at %.0f m/s", power))
charge = 0
end
end)
input.pressed(id)is true for one frame when the action starts,input.held(id)for as long as it's on, andinput.value(id)gives an analog value (a trigger pulled half way is 0.5).- "Released" is "held last frame, not now": keep a variable (
charging) that remembers last frame. - Key names are SDL's:
"T","Space","Left Shift","Right Ctrl","Up","F5","Keypad 1".
Toggles, and the standard actions¶
Every game made from the starter template already has the character
actions: move, look, jump, sprint, walk, crouch, fire,
aim, interact and camera.toggle, on keyboard and mouse and on a
controller. Scripts read them like their own. Here L switches a lamp
on and off, and holding sprint makes it glow brighter.

-- toggle.lua: one key, two ways. L switches a lamp on and off (pressed:
-- once per press), and holding the engine's own "sprint" action makes it
-- glow brighter while held. (docs/cookbook/input.md)
input.define("lamp", "Lamp on/off", "L")
local lamp = nil -- the lamp's body id while it's on
local bright = false
local function setLamp(on, glow)
if lamp then physics.remove(lamp); lamp = nil end
if on then
local c = glow and Vec(1, 1, 0.7) or Vec(0.9, 0.7, 0.3)
lamp = physics.sphere { pos = Vec(-2, 1.2, 3), radius = glow and 0.4 or 0.3, color = c, static = true }
end
end
setLamp(true, false)
hook.Add("Think", "toggle.keys", function()
if input.pressed("lamp") then
setLamp(lamp == nil, bright) -- on if it was off, off if on
end
-- The standard character actions (move, jump, sprint, ...) are there
-- too: read them the same way.
local sprinting = input.held("sprint")
if lamp and sprinting ~= bright then
bright = sprinting
setLamp(true, bright)
end
end)
Show which button to press¶
Never write "press E": on a controller there is no E. A prompt is a picture of the button, on whatever the player is holding, and it changes the moment they pick up something else. Here are the same six prompts on a keyboard, an Xbox pad, a DualSense, a Switch pad, a Steam Deck and a touch screen (the pictures are Xelu's free prompts):

-- prompts.lua: show the player which button to press, as a picture of
-- that button on the device they are using right now: a key on the
-- keyboard, A on an Xbox pad, Cross on a PlayStation pad, a tap on a
-- touch screen. Pick up another device and every prompt changes by
-- itself. (docs/cookbook/input.md)
-- Your own action: G on the keyboard, the right bumper on a controller.
input.define("greet", "Wave hello", "G", "rb")
-- <prompt> is an RmlUi element the engine adds: give it an action (or a
-- raw button like "touch:hold") and a label.
local panel = ui.open([[
<rml>
<head>
<style>
body { font-family: Noto Sans; font-size: 22dp; color: #f2f4f8; }
div { display: block; }
#panel { position: absolute; left: 40dp; top: 40dp; width: 520dp; padding: 16dp 24dp;
background-color: #10141ecc; border-radius: 12dp; }
#panel div { margin: 6dp 0; }
#style { font-size: 16dp; color: #9aa3b8; }
</style>
</head>
<body>
<div id="panel">
<div id="style"></div>
<div><prompt action="move" label="Move"/></div>
<div><prompt action="jump" label="Jump"/></div>
<div><prompt action="sprint" label="Sprint"/></div>
<div><prompt action="fire" label="Shoot"/></div>
<div><prompt action="greet" label="Wave hello"/></div>
<div><prompt action="ui.accept" label="Accept"/> <prompt action="ui.back" label="Back"/></div>
<div id="said"></div>
</div>
</body>
</rml>]])
local function showStyle()
ui.text(panel, "style", "Prompts for: " .. input.style())
end
showStyle()
-- InputStyle runs when the player switches devices. <prompt> elements
-- redo themselves; text you built with input.promptText needs redoing.
hook.Add("InputStyle", "prompts.style", function(style)
print("now showing prompts for " .. style)
showStyle()
end)
hook.Add("Think", "prompts.greet", function()
if input.pressed("greet") then
-- {action} in a string becomes that button's picture.
ui.rml(panel, "said", input.promptText("Hello! Press {greet} again to wave twice."))
end
end)
-- The picture's file, to use in your own <img> or HUD.
print("the greet button's picture: " .. tostring(input.glyph("greet")))
<prompt action="jump" label="Jump"/>in a document needs no code at all;input.promptText("{jump} jump")makes the same markup forui.rml, andinput.prompt("jump", "Jump")makes one.{a},{rb},{key:Space},{mouse:left},{touch:tap}name a button that isn't an action ("press A to join").- Give your actions a controller button (
input.define's fourth argument) or pads show nothing for them. - Try it without a controller:
KKE_PROMPT_STYLE=playstationstarts the game showing PlayStation prompts.
Binding in C++: pads, holds, chords and axes¶
In C++ you get everything the bindings screen can do. The cookbook game
adds its own controls in games/cookbook/Bindings.h: a button on both
keyboard and pad, a hold and a release on one key, a Ctrl chord, and an
axis driven by two keys and both triggers.
inline void addCookbookBindings(kke::InputMap& in) {
using kke::InputModule;
using kke::Trigger;
// A button action: Tab on the keyboard, d-pad right on a pad (View/Back
// and Start belong to the pause menu every game shares).
in.defineAction({ "camera.next", "Next camera", "Camera" });
in.addBinding(InputModule::bind("camera.next", InputModule::key(SDL_SCANCODE_TAB)));
in.addBinding(InputModule::bind("camera.next", InputModule::pad(SDL_GAMEPAD_BUTTON_DPAD_RIGHT)));
// Hold to charge, release to throw: two actions on one key.
in.defineAction({ "throw.charge", "Charge a throw", "Actions" });
in.defineAction({ "throw", "Throw", "Actions" });
in.addBinding(InputModule::bind("throw.charge", InputModule::key(SDL_SCANCODE_Q), Trigger::Hold));
in.addBinding(InputModule::bind("throw", InputModule::key(SDL_SCANCODE_Q), Trigger::Release));
// A chord: Ctrl+R resets the level; plain R stays free for something else.
in.defineAction({ "level.reset", "Reset the level", "Game" });
kke::Binding reset = InputModule::bind("level.reset", InputModule::key(SDL_SCANCODE_R));
reset.modifiers.push_back(InputModule::key(SDL_SCANCODE_LCTRL));
in.addBinding(reset);
// An axis from two keys and a trigger: zoom in with X or the right
// trigger (analog: half pulled = half speed), out with the left one.
in.defineAction({ "zoom", "Zoom", "Camera", "game", kke::ActionType::Axis1D });
kke::Binding in1 = InputModule::bind("zoom", InputModule::key(SDL_SCANCODE_X), Trigger::Continuous);
in.addBinding(in1);
kke::Binding out1 = InputModule::bind("zoom", InputModule::key(SDL_SCANCODE_Z), Trigger::Continuous);
out1.scale = -1.0f;
in.addBinding(out1);
kke::Binding padIn = InputModule::bind("zoom", InputModule::padAxis(SDL_GAMEPAD_AXIS_RIGHT_TRIGGER), Trigger::Continuous);
padIn.deadzone = 0.1f;
in.addBinding(padIn);
kke::Binding padOut = InputModule::bind("zoom", InputModule::padAxis(SDL_GAMEPAD_AXIS_LEFT_TRIGGER), Trigger::Continuous);
padOut.deadzone = 0.1f;
padOut.scale = -1.0f;
in.addBinding(padOut);
}
Call it from your module's init, after the standard actions, then save
them as the defaults the settings screen resets to:
kke::InputMap& in = m_input->map(0); // player 0; split screen has more
kke::InputModule::defineCharacterActions(in);
cookbook::addCookbookBindings(in);
m_input->commitDefaults();
and read them in update the same way Lua does:
if (in.pressed("camera.next")) nextCamera();
if (in.held("throw.charge")) charge += dt;
if (in.pressed("throw")) throwBall(charge);
zoom -= in.axis("zoom") * 4.0f * dt; // -1 .. 1
const glm::vec2 move = in.axis2("move"); // stick or WASD, length up to 1
The unit test Cookbook.BindingsDoWhatTheInputPageSays
(tests/test_cookbook.cpp) presses these keys and buttons on a fake
keyboard and pad and checks each action does what this page says.
Triggers at a glance¶
| Trigger | Fires | Good for |
|---|---|---|
Press |
on the way down, held while down | jump, interact |
Release |
on the way up | throw on release |
Hold |
after holdTime (0.35 s) held |
charge, hold to lean |
Tap |
released within tapTime |
quick actions sharing a key with a hold |
DoubleTap |
second press within 0.3 s | dodge, quick reload |
Continuous |
the whole time, with its analog value | movement, zoom, throttle |
Toggle |
each press flips it | crouch, walk |
What players change is saved in input.json next to the game; the
defaults stay in your code. Left-handed layouts, twin flight sticks and
rebinding screens are in Input.
Next: move things around.