Keyboard, mouse, and the action map.
Part of the scripting guide · every call, as a reference
Available while playing.
| Call | Returns |
|---|---|
input.key("w") |
true while the key is held |
input.pressed("space") |
true only on the frame it goes down (an edge) |
input.released("space") |
true only on the frame it goes up (an edge) |
input.typed() |
the characters entered this frame, as a string (see below) |
input.axis("a", "d") |
-1 / 0 / 1 from a negative/positive key pair |
input.button(1) |
mouse button held (0 left, 1 right, 2 middle) |
input.clicked(1) |
mouse button pressed this frame (an edge) |
local dx, dy = input.mouse_delta() |
mouse movement since last frame |
local x, y = input.mouse() |
cursor position, pixels |
input.scroll() |
wheel delta this frame |
input.setMouseLocked(true) |
pin + hide the cursor (FPS mouselook); false releases. Also input.lockMouse() / input.unlockMouse() |
input.touches() |
every finger on the screen this frame (see below) |
input.touchBegan(id) / input.touchEnded(id) |
that finger went down / lifted this frame |
Clicking into the Game view pins the pointer there, so playing doesn't let the mouse wander onto editor panels. The editor skips that if your game already has something clickable on screen — a menu's buttons are the gameplay, and pinning the pointer froze them dead.
That question is asked every frame, so a shop or a pause menu that opens two minutes into a session takes the pointer back on its own; you don't have to do anything, and the player doesn't have to know that Escape was an option.
input.setMouseLocked(false) also releases it explicitly, which is what to
reach for if your menu is drawn some way the editor can't recognise as
interactive (draw.*, a shader, your own hit-testing).
Before 0.26.0 the question was only asked at the click that pinned the pointer. A game with no visible buttons during play — a twin-stick shooter, anything cursor-free — could never get the cursor back, and
setMouseLockeddid not release it either, because that is a separate lock owner. Its own menus were unclickable for the rest of the session.
While a game holds the cursor, Escape gives it back to you and the game can't take it again until you click into the Game view. The Game view says so along its bottom edge, in both directions — a grabbed cursor is invisible, so the one thing that says how to get it back can't be the cursor.
That matters because a first-person camera calls setMouseLocked(true) from
update, on every single frame, which is the correct way to write one. Escape
does not fight that call: your game keeps asking, the editor keeps saying no,
and the moment you click back into the view your camera has the mouse again
with nothing to re-establish. Leaving the window does the same thing, so
alt-tabbing away and back lands you on a usable pointer rather than a grabbed
one.
While the editor is holding it, your game reads a neutral mouse — no motion, no buttons, no wheel, and no action bound to any of them. Its keyboard and gamepad are untouched, because it's still playing. So you can tune a value in the Inspector mid-flight without the view spinning to follow the pointer and without every click on a slider also firing your weapon.
Before 0.40.1 Escape cleared the lock for exactly one frame and the next
updatetook it straight back, so a first-person game in the editor had a cursor you could only recover by alt-tabbing out of the whole application.
Key names are the same names the action map's key picker shows (Project Settings ⏵ Input) — one list, so a key you can bind is a key you can poll:
a–z,0–9,f1–f12spaceenterescapetabbackspacedeleteinserthomeendpageuppagedownshiftctrlaltsupercapslock(left and right collapse onto one name)- arrows
leftrightupdown ,./;'`[]\-=- numpad
num0–num9num+num-num*num/num.
Before 0.21.1 the raw-key half of that list stopped at the arrows, so
input.pressed("f9")— and every numpad, bracket and navigation key — was permanentlyfalsewhile the same key bound fine in Settings. If you worked around it, the workaround is no longer needed.
Every key in that list, with three exceptions the editor keeps for itself:
| key | what takes it |
|---|---|
f1 |
Play / Stop |
f2 |
Pause |
f3 |
Step one tick (Shift+F3 steps back) |
Those are the transport controls, and a game that could take f1 could stop you
stopping it — which is the one key you need when a script has gone wrong. Polling
one of them writes a Console line the first time, and the Scripting tab lints it
where you wrote it, so it is never a mystery.
Everything else is the game's, tab included. That is worth stating because
until 0.33.0 it was not: egui gave Tab to the editor's own focus traversal
before the game saw it, so a press cycled the editor's panels and
input.pressed("tab") returned false. Tab is the convention
for opening an inventory — Minecraft, Terraria, Valheim, Don't Starve — so it is
the first key both a player and a developer reach for, and the failure had no
symptom: false is exactly what a key nobody pressed looks like. A game shipped a
bag on Tab, passed its headless tests, and heard about it from a player. It also
worked in an exported build, so the binding looked broken for the whole time you
were making the game and correct only after you stopped testing it. A focused Game
view now claims the keyboard in the editor and in a build alike.
A locked cursor is genuinely pinned to the window center (hardware lock where
the OS supports it, per-frame re-centering where it doesn't) — read motion with
input.mouse_delta(). Stop always releases the lock.
input.pressed is a key; input.typed is a character. input.pressed("q")
asks about the physical key where Q sits on a QWERTY board — on AZERTY that
key types a, and nothing in the name says so. input.typed() returns what
the player meant to write, resolved by the OS layout, with a paste (Ctrl/Cmd-V)
folded into the same string. It never contains control characters: Enter and
Backspace stay actions.
code = code .. input.typed()
if input.pressed("backspace") then code = code:sub(1, -2) endBuilding a string by polling a–z gets the alphabet wrong for anyone whose
keyboard isn't yours, and gets it wrong for digits and punctuation on every
keyboard. For anything more than a few characters, use a UI text field —
it brings a caret, selection, the clipboard and key repeat with it
(ui-navigation.md). input.typed() is empty while a field
has focus, because the field consumed them.
While a game plays in the editor, F1 stops it, F2 pauses, F3 steps one tick (Shift+F3 back one), so a game never sees those three there. In an exported build F1 opens the multiplayer panel, F11 and Alt+Enter toggle full screen, and F2 and F3 are the game's. Bind other keys for anything a player must reach in both.
input.simulate{ key = "w", down = true } presses a key as if the player had,
from the next frame on, for every script: input.key, input.pressed,
input.released and input.action all see it. down = false releases it. It
is a testing aid for a probe or a scripted test run under floptle run, not a
way for a game to move its own character.
The mouse, the wheel and the keyboard's text simulate the same way, and the game's UI sees them as it sees a player's:
| Call | Does |
|---|---|
input.simulate{ button = 0, down = true } |
a mouse button, numbered as input.button numbers them: 0 left, 1 right, 2 middle, 3 and 4 the side buttons |
input.simulate{ mouse = {x, y} } |
moves the pointer, in the pixels input.mouse() reports (a vec2 works too) |
input.simulate{ scroll = 1 } |
turns the wheel; input.scroll() reads it |
input.simulate{ text = "hello" } |
types, into input.typed() or the focused text field |
One call can carry several, applied in that order — pointer, wheel, text, key,
button — so a probe clicks a menu button with
input.simulate{ mouse = {x, y}, button = 0, down = true } and then
input.simulate{ button = 0, down = false }. A press and its release in the
same frame still click, as a quick real click does.
input.key("space") asks about one key, and a player's rebind cannot change
which key that is. Rebinding — input.startRebind, and the Controls page
ui.engineSettings builds — rewrites actions, so a control a player can
rebind has to be read as one. The move is mechanical, one control at a time:
- Name the control. Project Settings ⏵ Input lists every name your scripts
already pass to
input.action, so writing the call first is enough to make the row appear; give it the key it used to read (and a pad button, while you are there). The map lives ininput.ron. - Replace the read, edge for edge:
| Raw key read | Action read |
|---|---|
input.key("space") |
input.action("Jump") |
input.pressed("space") |
input.justPressed("Jump") |
input.released("space") |
input.justReleased("Jump") |
input.axis("a", "d") |
input.axis1("MoveX"), an axis bound to the pair |
-- before: fixed to the space bar
if input.pressed("space") and node.grounded then jump() end
-- after: whatever the player bound, keyboard or pad
if input.justPressed("Jump") and node.grounded then jump() endKeep raw reads for what is not a control: a debug key, typed text
(input.typed()), a key the game shows by name in a tutorial line it builds
from input.bindingsOf. Project Settings ⏵ Input lists the raw key reads left
in your scripts beside the actions, which is the worklist.
A phone has no mouse and no keyboard: a game played on one reads fingers.
input.touches() lists every finger on the screen this frame, each a table:
| Field | Meaning |
|---|---|
id |
the same number for the whole life of one touch |
x, y |
where it is, in input.mouse()'s pixels |
dx, dy |
how far it moved this frame |
phase |
"began", "moved", "held" (down and still), "ended" or "cancelled" |
pressure |
0..1 where the screen reports it, else 1 while down |
began |
it went down this frame (true even for a tap that also ended in it) |
ui |
it landed on your UI and is working it, so your own controls should skip it |
A finger that lifts is listed once more, as ended (or cancelled, when the
system took it away), and then it is gone. Several fingers are independent: a
move stick, a look drag and a fire button work at the same time.
Your UI works by touch with no code. A finger that lands on an interactive
element (a button, a slider, a field) works it as the mouse would: a tap fires
onClicked, a drag moves a slider. A finger that lands anywhere else is only
the game's, so a stick dragged across a button never presses it.
app.isTouch() is true when the player's main pointer is a finger: a phone or
a tablet in a browser, or any screen that has been touched. Use it to start in
your touch layout. Looking around with a finger needs no lockMouse; read the
finger's dx, dy instead.
A move stick, the recipe every touch game starts from:
local stick = { id = nil, cx = 0, cy = 0, x = 0, y = 0 }
local RADIUS = 90
function update(self, dt)
local x0, y0, w = camera.screenRect()
for _, t in ipairs(input.touches()) do
if t.began and not t.ui and t.x < x0 + w * 0.4 and stick.id == nil then
stick.id, stick.cx, stick.cy = t.id, t.x, t.y -- the stick appears under the thumb
end
if t.id == stick.id then
local dx, dy = t.x - stick.cx, t.y - stick.cy
local len = math.sqrt(dx * dx + dy * dy)
local k = len > RADIUS and RADIUS / len or 1
stick.x, stick.y = dx * k / RADIUS, dy * k / RADIUS -- -1..1 each way
if t.phase == "ended" or t.phase == "cancelled" then
stick.id, stick.x, stick.y = nil, 0, 0
end
end
end
-- move by stick.x, stick.y the way you would by input.axis
endIn a browser build the page keeps the browser's own gestures off the game: a
drag does not scroll the page or pull it to refresh, and two fingers do not
zoom it. app.setFullscreen(true) works from a phone too: when the browser
wants a tap first, the game goes fullscreen on the player's next one. iPhone
Safari has no fullscreen for a web page, and there it does nothing.
Every other call here answers the resolved question — did this player press Jump. None of them can answer the one underneath it: is there a controller here at all. So "the pad was never enumerated", "it went into a slot the map doesn't bind", "the window hasn't got focus" and "something downstream ate it" all reach you as the same observation — nothing happens — and the bug report that comes back is "controllers don't work".
| Call | Returns |
|---|---|
input.pads() |
a list of { index, name, connected }, index 1-based |
input.padCount() |
how many pads are connected right now |
input.padButton(1, "South") |
that pad's button, raw — no action binding involved |
input.padAxis(1, "LeftStickX") |
that pad's axis, raw, −1..1 (triggers 0..1) |
for _, p in ipairs(input.pads()) do
log(string.format("pad %d: %s%s", p.index, p.name, p.connected and "" or " (gone)"))
endIf the list is empty it is a device problem and nothing about your input map
matters. If the pad is listed but input.action(...) stays false, the pad is
fine and the binding is where to look — and input.padButton will tell you
the button is physically down while the action is not firing. Put that on a
controls screen and a player can diagnose it for you.
The list follows hot-plug: poll it, and a disconnected pad reads neutral rather
than freezing its last pose. Button and axis names are the variant names
(South, East, LeftBumper, Start, LeftStickX, RightZ, …), matched
case-insensitively; an unknown name reads false/0 rather than erroring.
A binding can be scoped to a local player slot, which is what lets one Move
axis carry WASD for player 1 and the arrow keys for player 2 — and one pad per
player:
Stick( player: Some(0), id: Slot(0), x: LeftStickX, y: LeftStickY, deadzone: 0.25 ),
Stick( player: Some(1), id: Slot(1), x: LeftStickX, y: LeftStickY, deadzone: 0.25 ),player is available on every binding form — actions, Keys, Stick and
Analog. Without it two pads do not mean two players: Slot(n) names a
device, not a player, so an unscoped pair contributes both sticks to both
players and the harder push drives both characters.
A player-scoped id: Any means that player's own pad, or nothing — so a
second player with no pad reads zero instead of mirroring the first player's
stick. Unscoped Any keeps its old meaning: the resolving player's pad, else
the first connected one.
Turn a world point into a screen pixel (and back) against the active game
camera — the pixels are in the same space input.mouse() reports, so you can
hover and click 3-D things you drew:
| call | returns |
|---|---|
camera.worldToScreen(x, y, z) |
sx, sy, depth, onscreen |
camera.screenToRay(sx, sy) |
ox,oy,oz, dx,dy,dz (a world ray from a pixel) |
camera.screenSize() |
w, h (game viewport, pixels) |
camera.screenRect() |
x, y, w, h — the viewport's rectangle in cursor space. Use this, not screenSize, to ask "is the cursor over the game view?": in the editor the view is a docked panel, so input.mouse() carries its offset. (An edge-pan camera that compares the cursor's x against the width slides away forever the moment the panel is not at x = 0.) |
camera.exists() |
true once a live game camera is being fed |
onscreen is false for points behind the camera or outside the frustum — skip
those. Click-on-line picking (how the solar map's maneuver nodes are placed):
sample a drawn line into points, worldToScreen each, and keep the nearest to
input.mouse() within a pixel threshold; create at that point on input.clicked(0).
local mx, my = input.mouse()
local best, bd
for _, p in ipairs(orbit_points) do
local sx, sy, _, on = camera.worldToScreen(p.x, p.y, p.z)
if on then
local d = (sx - mx) ^ 2 + (sy - my) ^ 2
if not bd or d < bd then best, bd = p, d end
end
end
if best and bd < 18 * 18 and input.clicked(0) then create_node_at(best) endraycast(ox,oy,oz, dx,dy,dz, max [, ignore]) casts a ray against the world's
colliders (the terrain and any walkable mesh colliders) and every physics
body (players, crates) and returns a hit table or nil:
-- ground within 1.2 units below me?
local h = raycast(node.x, node.y, node.z, 0, -1, 0, 1.2)
if h then
-- h.x, h.y, h.z the hit point
-- h.nx, h.ny, h.nz the surface normal there
-- h.distance how far the ray travelled
-- h.node the node that was hit — a body OR a piece of level
-- h.material which material slot of it, where that means anything
endh.node tells you what you hit, whether that is a crate, another player or the
wall of a room: h.node:getscript("combat") reaches its scripts, and
h.node:material() its material. Your own node's body never blocks your rays,
and the optional ignore arg skips one more node's body — the orbit camera
passes the character it follows, so it never reads as a wall.
One map mesh usually has several materials on it: a building is one node and its
brick, its grass and its floorboards are three of its material slots. So
h.node:material() answers the node's material, which for a level of any size
is one answer for a lot of different ground. h.material is the slot the ray
actually landed on — the name the level author typed, the one the Inspector
shows — which is what a footstep, an impact decal or a splash needs:
-- Pick a footstep from the floor rather than from a volume drawn over the room.
local h = raycast(node.x, node.y, node.z, 0, -1, 0, 1.2, { layers = "Level" })
if h and h.material == "Boards" then
audio.play("footstep_wood")
elseif h and h.material == "Grass" then
audio.play("footstep_grass")
endIt is nil for anything that has one surface, and that is deliberate: a
terrain, a Collidable cube, an imported model, a physics body. A name invented
for those would be right for map meshes and quietly wrong everywhere else, which
is worse than no name. Branch on h.material and fall back to
h.node:material() when it is nil.
It costs nothing until you read it. The slot is looked up when you ask,
not when the hit is built, so a line-of-sight ray that only reads h.distance
pays for none of it. Read it once into a local if you need it twice.
The last argument can instead be an options table, which also filters by layer:
-- only the ground can block this ray — other players/props never will
local h = raycast(x, y, z, 0, -1, 0, 2.0, { ignore = target, layers = { "Ground" } })layers takes one name or an array (Project Settings → Layers) and filters
both static geometry and bodies; a misspelled layer name is an error, not a
silent miss.
Use it for ground checks, line-of-sight, shooting, or dropping objects onto a surface.
(The built-in node.grounded already does a robust contact check for the character;
raycast is the general-purpose tool for everything else.)
When the rays are known up front (the corners of a decal, a ragdoll's contact probes, a fan of sight lines), cast them in one call:
local hits = raycastMany(origins, dirs, 0.5, { layers = "Level" })
for i, h in ipairs(hits) do
if h then place(i, h) end -- a hit table, or false for a miss
endorigins and dirs are arrays of vec3s (or flat {x1,y1,z1, x2,...} arrays),
one of each per ray. maxes is one distance for all of them or a list of one
per ray. The options are raycast's. The answer has one entry per ray, in order:
the same hit table raycast returns, or false where it would return nil, so
#hits is always the number of rays.
A ray only asks the colliders whose bounds it passes through, and a mesh
collider answers by walking its own triangles along the ray, so a short ray
costs about a microsecond on a level of a hundred models. perf.counts().rays
is how many rays scripts cast last frame and perf.counts().rayMs what they
took (with perf.enable(true); the time is part of scripts).
The sweeps below are counted the same way, in perf.counts().sweeps and
sweepMs.
A ray answers what is along this line. A melee swing, an explosion or a "can I fit there" asks a different question — what is inside this volume — and a fan of rays answers it badly: it misses anything thinner than the fan and cannot tell you how deep the overlap was.
-- Everything within 2 m of the sword, deepest overlap first.
for _, hit in ipairs(overlapSphere(swordTip, 2.0, { layers = "Enemies" })) do
combat.hurt(hit.node, 25)
end
-- A thrown rock: a swept sphere hits what a ray squeaks past.
local h = spherecast(node.pos, vel:normalized(), 0.4, 30, { layers = {"Ground","Props"} })
-- "Can I actually walk there", asked with the shape that will be walking.
local blocked = capsulecast(node.pos, moveDir, 0.4, 0.9, 1.5)| call | result |
|---|---|
overlapSphere(center, radius [, opts]) |
a list of hits, deepest overlap first (empty when nothing is inside) |
spherecast(origin, dir, radius, max [, opts]) |
the first hit, or nil |
capsulecast(origin, dir, radius, halfHeight, max [, opts]) |
the first hit, or nil |
boxcast(origin, dir, halfX, halfY, max [, opts]) |
the first hit, or nil, for a box in the XY plane — a 2D body |
overlapBox(center, halfX, halfY [, opts]) |
a list of what a box in the XY plane overlaps, deepest first |
The two box queries are the 2D ones: a box held in the XY plane, the shape a
2D body is. boxcast sweeps it by its edges, so it meets a step lower than its
middle, and a box resting on the floor can sweep along the floor without
hitting it:
-- Is there room to dash four units to the right?
local h = boxcast(node.pos, vec3(1, 0, 0), 0.4, 0.5, 4)
local room = h and h.distance or 4Hits carry the same fields a raycast hit does — x/y/z, nx/ny/nz,
distance, node and material — so a
script that handles one handles the others. For an overlap, distance is the
penetration depth rather than a travel distance. opts is the same table
raycast takes, and your own body is skipped for you.
These are cheap here for a structural reason: every collider already answers a signed distance, so an overlap is one distance test and a swept sphere is the ray march with the radius subtracted. Unlike a ray, they also see sensors — a hitbox usually does want to know it swept a trigger volume.
A sweep past an assembly marches only the parts near its path, and an assembly
named in ignore by its root costs nothing to march at all: a chase camera
beside a thousand-part craft pays for the parts it passes, not for the craft.
Draw one-frame debug shapes over the viewport straight from code. They show in
the Scene view only (the Game view stays clean — it's what the player would
see), and the viewport's gizmos toggle hides them all. Colors are optional
0–1 floats (default green); everything is immediate mode — call it every
frame you want the shape visible.
| Call | Draws |
|---|---|
gizmo.line(x1,y1,z1, x2,y2,z2 [, r,g,b]) |
a world-space line |
gizmo.ray(ox,oy,oz, dx,dy,dz [, len [, r,g,b]]) |
origin + direction (with len the direction is normalized — mirrors raycast) |
gizmo.sphere(x,y,z [, radius [, r,g,b]]) |
a wire sphere (trigger zones, blast radii) |
gizmo.point(x,y,z [, size [, r,g,b]]) |
a small 3-axis cross (hit points, waypoints) |
-- visualize a ground probe: green when it hits, red when it misses
local h = raycast(node.x, node.y, node.z, 0, -1, 0, 1.5)
if h then
gizmo.ray(node.x, node.y, node.z, 0, -1, 0, 1.5, 0.3, 1.0, 0.4)
gizmo.point(h.x, h.y, h.z, 0.2)
else
gizmo.ray(node.x, node.y, node.z, 0, -1, 0, 1.5, 1.0, 0.35, 0.3)
endThe bundled character controllers ship with exactly this: set their debug_ray
param to 1 in the Inspector and the ground-check probe draws itself.