luaguides

Keyboard, Mouse, and Gamepad Input

Prerequisites

You should be comfortable with love.load, love.update, and love.draw already, and you should know what a callback is — input handling is nothing but a stack of callbacks plus a poll function. You don’t need to know object-oriented Lua, but the examples use method-style calls like player:jump(), so skim the Tables in Lua article if player:jump() vs player.jump(player) looks foreign.

You’ll also want LÖVE 11.x installed locally so you can run the snippets as you read. If you haven’t reached “I have a window with a colored rectangle” yet, start with the Getting Started with LÖVE2D article and come back. Nothing in this tutorial will compile without a working main.lua.

Introduction

LÖVE 11.x reports input through three families of global callbacks: love.keypressed and love.keyreleased for keyboard events, love.mousepressed, love.mousemoved, and love.wheelmoved for pointer input, and love.joystickadded, love.joystickpressed, and love.gamepadpressed for controllers. Each family has its own mental model, and confusing them is the single most common reason a beginner’s player sprite refuses to move.

The rule that catches everyone on day one: love.keypressed fires once per physical press. It does not run every frame. If you write movement code inside love.keypressed, your sprite will move a single pixel and then stop. Held-state detection, the kind you need for “WASD moves the player at 220 pixels per second”, belongs in love.update(dt), where you call love.keyboard.isDown(...) on every frame. The rest of this article is mostly a tour of which callback does which job, and which one you’ve been calling by mistake.

This tutorial sits in the middle of the LÖVE 2D series.

Keyboard Input

The keyboard module exposes a small, deliberate API. You react to one-shot events with love.keypressed, and you poll for held keys with love.keyboard.isDown(...). Resist the temptation to do anything else in between.

Press once vs. hold

Here’s the canonical layout. Single-shot actions go in love.keypressed; continuous movement goes in love.update.

local player = { x = 400, y = 300 }
local SPEED  = 220

function love.keypressed(key, scancode, isrepeat)
  if isrepeat then return end           -- ignore OS auto-repeat
  if key == "escape" then love.event.quit() end
  if scancode == "space" then
    -- single-shot: shoot, jump, confirm a menu option
  end
end

function love.update(dt)
  local dx, dy = 0, 0
  if love.keyboard.isDown("a", "left")  then dx = dx - 1 end
  if love.keyboard.isDown("d", "right") then dx = dx + 1 end
  if love.keyboard.isDown("w", "up")    then dy = dy - 1 end
  if love.keyboard.isDown("s", "down")  then dy = dy + 1 end

  -- normalize diagonals so 45° doesn't move faster than straight
  if dx ~= 0 and dy ~= 0 then
    local inv = 1 / math.sqrt(2)
    dx, dy = dx * inv, dy * inv
  end

  player.x = player.x + dx * SPEED * dt
  player.y = player.y + dy * SPEED * dt
end

Two small details in there are worth pausing on. First, isrepeat is the OS auto-repeat flag. When the player holds the key, LÖVE calls love.keypressed again with isrepeat = true. Gate single-shot handlers behind if isrepeat then return end or your “open menu” code fires dozens of times per second. Second, love.keyboard.isDown accepts variadic arguments, so isDown("a", "left") is true if either the A key or the left arrow is held. That’s the canonical idiom for “any of these keys does this action”, and it also lets you build dual keyboard-and-gamepad bindings into a single check.

Scancodes vs. key constants

love.keypressed hands you two identifiers per key: key (a KeyConstant like "a", "space", "f1") and scancode (the physical position of the key on a US-QWERTY keyboard). The distinction matters because the same physical key produces different key values on different layouts.

Press the key labelled “W” on a French AZERTY keyboard. LÖVE hands you key = "z", scancode = "w". Bind to scancode for movement and your game works on every layout. Bind to key = "w" and a quarter of the planet can’t play.

local DEFAULTS = {
  up    = "w",
  down  = "s",
  left  = "a",
  right = "d",
  jump  = "space",
}

function love.keypressed(key, scancode)
  if scancode == DEFAULTS.jump then
    player:jump()
  end
end

Use key only when you want the character the user typed (text fields, save-name entry). For default controls, scancode every time.

Text input for name fields and chat

For real character entry, like typing a save name or sending a chat message, listen to love.textinput. It hands you a UTF-8 string of the resolved character, including composited accents and IME candidates.

function love.load()
  love.keyboard.setTextInput(true)   -- required on iOS / Android
  name = ""
end

function love.textinput(text)
  name = name .. text
end

function love.keypressed(key)
  if key == "backspace" then
    name = name:sub(1, -2)           -- byte-based; see UTF-8 note below
  end
end

love.keyboard.setTextInput(true) is mandatory on iOS and Android. Without it, the on-screen keyboard never appears, and love.textinput stays silent. On those platforms also check love.keyboard.hasScreenKeyboard() before showing text-entry UI, so you don’t offer a field the player can’t actually type into.

One caveat: string.sub works on bytes, not codepoints. For ASCII names the snippet above is fine; for emoji, accented characters, or anything outside the BMP, switch to the standard utf8 library (utf8.char, utf8.codepoint, utf8.len). The input wiring is the same; the difference is in how you trim the string afterwards.

Mouse Input

Mouse input follows the same press-once / hold pattern. love.mousepressed fires once per click; for hover effects and drag tracking you read love.mouse.getPosition() (or love.mousemoved) every frame.

Clicks, hover, and the scroll wheel

function love.mousepressed(x, y, button)
  if button == 1 and play_button:contains(x, y) then
    game:start()
  end
end

function love.mousemoved(x, y)
  play_button.hovered = play_button:contains(x, y)
end

function love.wheelmoved(x, y)
  camera.zoom = math.max(0.25, math.min(4, camera.zoom + y * 0.1))
end

Three things in there that trip people up. Button 1 is primary (left), 2 is secondary (right), 3 is middle, and 4 and 5 are thumb buttons. That numbering is consistent across love.mousepressed and love.mouse.isDown. The wheel does not show up as a button press; since LÖVE 0.10.0 the only way to read it is love.wheelmoved(x, y), where y > 0 means scroll up. And the istouch parameter you might see in love.mousepressed(x, y, button, istouch) is true when the click came from a touch screen, which is useful for suppressing hover effects on phones, where “hover” never happens.

Relative mode for first-person cameras

For FPS-style mouse-look, call love.mouse.setRelativeMode(true). The cursor vanishes, the mouse is locked to the window, and love.mousemoved hands you only dx and dy deltas. The absolute x and y are not guaranteed to update while the mode is active.

function love.load()
  love.mouse.setRelativeMode(true)
end

function love.mousemoved(x, y, dx, dy)
  camera.yaw   = camera.yaw   - dx * 0.0025
  camera.pitch = math.max(-math.pi/2,
                math.min(math.pi/2, camera.pitch - dy * 0.0025))
end

The 0.0025 is a sensitivity constant. There’s no “right” value. Expose it in your settings menu, and remember that some players will want it ten times higher than the default.

Custom cursors

If love.mouse.isCursorSupported() returns true (true on desktop, false on mobile), you can hand LÖVE an image and it will be drawn by the OS, with no per-frame work in your game.

local img    = love.image.newImageData("cursor.png")
local cursor = love.mouse.newCursor(img, 8, 8)   -- hot spot at (8, 8)
love.mouse.setCursor(cursor)

Hardware cursors stay smooth even when your game is running at 30 fps, which is why serious UI work uses them instead of love.graphics.draw("cursor.png", mouse_x, mouse_y).

Gamepad Input

LÖVE exposes controllers in two parallel layers. The hardware layer is love.joystickpressed(js, buttonN), where buttonN is a 1-based integer and the meaning of “button 1” depends on the pad. The virtual layer is love.gamepadpressed(js, button), where button is a GamepadButton constant like "a", "leftshoulder", or "dpup", the same on every pad LÖVE recognizes as a gamepad.

The virtual layer only fires for joysticks where joystick:isGamepad() returns true. For unrecognized controllers you have two options: fall back to the hardware layer and write per-pad mapping code, or call love.joystick.setGamepadMapping(guid, ...) to teach LÖVE the layout. Most projects pick the fallback and ship a settings menu instead.

Hooking up a controller

local joysticks = {}

function love.load()
  for _, js in ipairs(love.joystick.getJoysticks()) do
    table.insert(joysticks, js)
  end
end

function love.joystickadded(js)
  table.insert(joysticks, js)
end

function love.joystickremoved(js)
  for i, j in ipairs(joysticks) do
    if j == js then table.remove(joysticks, i); break end
  end
end

function love.gamepadpressed(js, button)
  if button == "a" then player:jump() end
end

A subtle but useful guarantee: love.joystickadded is called after love.load for controllers that were already plugged in at launch, so the ipairs loop in love.load plus the callback handler covers both startup and hot-plug cases. You don’t have to also re-scan in love.load for pads you might have missed.

Analog sticks and deadzones

love.gamepadaxis(js, axis, value) gives you a value in [-1, 1] for each analog input. The catch is that the stick never reports exactly 0 when released; it floats around 0.02 or 0.05 depending on the pad. Without a deadzone, your character slowly creeps to the left when the player isn’t touching the stick.

local DEAD = 0.2

function love.gamepadaxis(js, axis, value)
  if math.abs(value) < DEAD then return end
  if axis == "leftx" then player.vx = value * 320 end
  if axis == "lefty" then player.vy = value * 320 end
end

0.2 is a safe starting point. Lower it and sticks feel snappier but drift visibly; raise it and the pad feels mushy near the center. Players with worn-out sticks will need a higher deadzone, which is another thing worth exposing in your settings menu.

Putting it together: a small input module

Tying keyboard and gamepad into a single isDown(action) call makes the rest of your game code dramatically cleaner. Here’s a small input.lua you can drop into a project.

local Input = {}
Input.__index = Input

function Input.new()
  local self = setmetatable({}, Input)
  self.bindings = {
    up    = { "w",     "up" },
    down  = { "s",     "down" },
    left  = { "a",     "left" },
    right = { "d",     "right" },
    jump  = { "space" },
  }
  return self
end

function Input:isDown(action)
  for _, key in ipairs(self.bindings[action] or {}) do
    if love.keyboard.isDown(key) then return true end
  end
  return false
end

return Input

The Input module lives in its own file, conventionally named input.lua, sitting next to your main.lua. The return Input at the bottom is what makes the require call work: LÖVE loads the file, runs it, and hands you back whatever was returned. Once the module exists, the rest of your game treats input as a single uniform interface, no matter which device the player is holding. Wire it up in love.load:

input = require("input").new()

The require path is relative to your project’s main.lua, so if you keep your source files in a src/ directory, you would write require("src.input") instead. The double-dot in the path comes from Lua’s package loader, not LÖVE, and it means “look one level up from this file.” Once input is in scope, every callback and update function can read from it. Then in love.update your movement code reads like English:

if input:isDown("left") then player.x = player.x - SPEED * dt end
if input:isDown("right") then player.x = player.x + SPEED * dt end

Extending the same isDown to gamepads means adding a second loop that walks love.joystick.getJoysticks() and calls isGamepadDown for the action’s GamepadButton mapping. Once that table exists, the rest of your game never has to think about which device the player is holding.

Common Mistakes

A short list of bugs every LÖVE 2D newcomer writes at least once:

  • Putting movement in love.keypressed. The player jumps one frame and stops. Move it to love.update.
  • Binding to key instead of scancode. French, German, and Russian players can’t reach your controls.
  • Reading the mouse wheel as a button. It isn’t one. Use love.wheelmoved.
  • Forgetting isrepeat. A held Enter key fires love.keypressed dozens of times per second; gate single-shot handlers behind if isrepeat then return end.
  • No deadzone on analog sticks. Characters creep in the idle state. Add a 0.150.25 deadzone.
  • Forgetting love.keyboard.setTextInput(true) on mobile. The on-screen keyboard never appears, and love.textinput never fires.
  • Disabling joystick in conf.lua to “save memory.” You also disabled gamepad support. Nobody complained; they just uninstalled.

Conclusion

LÖVE 2D input handling boils down to a single rule: callbacks for events, polling for held state. Get that one rule right and the rest of the API (scancodes, mouse cursors, virtual gamepad buttons, vibration) is just a vocabulary to grow into. The next natural step is to wrap this input layer in a game state so that pressing Escape actually pauses the play state and opens a menu state, instead of quitting the whole process.

What’s next

The natural follow-up to input handling is game states. Right now pressing Escape calls love.event.quit() and tears the whole process down; with a real state machine, Escape can pause the play state, push a menu state on top, and resume cleanly when the player dismisses it. That is where the Game States with hump.gamestate tutorial picks up, and it builds directly on the input module from this article.

See also

The following articles pair well with this one: