Input

Learn how to capture input from mice, keyboards, and gamepads

It’s easy to capture input from just about anything. Let’s learn how to grab events from the keyboard, mouse, and gamepads.

Ruby 2D offers two styles for handling input:

  • Event handlers: register callbacks with on, perfect for event-driven code
  • Polling: check input state each frame inside update, great for game loops

Keyboard

There are three kinds of keyboard events: when a key is pressed for the first time, while it’s held down, and when it’s released. The first and third happen once per press; the middle one fires every frame the key is down.

Event handlers

The basic form gives you the full event payload:

on :key_down do |event|
  puts "Pressed: #{event.key}"
end

on :key_held do |event|
  puts "Holding: #{event.key}"
end

on :key_up do |event|
  puts "Released: #{event.key}"
end

The KeyEvent has two fields: type (:down, :held, :up) and key (a lowercase string). To match by value, use event.key?. It accepts a symbol or string:

on :key_down do |event|
  reset if event.key? :r
end

When you only care about a specific key, the kwarg form of on reads like a shortcut binding:

on(key_down: :escape) { close }

on key_down: :space do
  fire
end

You can match several keys at once with an array, or bind the same action to events from different sources in one call:

on key_down: [:left, :a] do
  player.move_left
end

on key_down: :right, gamepad_button_down: :dpad_right do
  player.move_right
end

Bareword keys take symbols (:space, :escape, :r), but the underlying field stays a string, so numbers and multi-word keys remain expressible:

event.key? '1'           # numeric keys
event.key? 'left shift'  # multi-word names

Polling

When using the Window class pattern, you can check key state directly in update:

def update
  close if key_pressed? :escape
  @x += 1 if key_held? :right
  puts 'released space' if key_released? :space
end

The names match the events: key_pressed? is true only on the frame the key was first pressed, key_held? is true every frame the key is currently down, and key_released? is true only on the frame it lifted.

Mouse

The mouse position is always available:

# Using the DSL
get :mouse_x
get :mouse_y

# Or using the Window class
Window.mouse_x
Window.mouse_y

When you want both at once, mouse_position returns them as [x, y]:

mx, my = mouse_position

Event handlers

Mouse buttons mirror the keyboard’s three-event model, pressed once, held every frame, released once:

on :mouse_down do |event|
  puts "#{event.button} pressed at (#{event.x}, #{event.y})"
end

on :mouse_held do |event|
  paint(event.x, event.y) if event.button? :left
end

on :mouse_up do |event|
  puts "#{event.button} released at (#{event.x}, #{event.y})"
end

on :mouse_scroll do |event|
  puts "Scrolled #{event.direction}: dx=#{event.delta_x} dy=#{event.delta_y}"
end

on :mouse_move do |event|
  puts "Mouse at (#{event.x}, #{event.y})"
end

on :mouse_leave do
  pause_input_effects   # cursor left the window
end

The MouseEvent has fields: type, button, direction, x, y, delta_x, delta_y. Use event.button?(:left) to test the button by value, and event.position / event.delta when you want the pair: mx, my = event.position. :mouse_enter and :mouse_leave are payload-free: only type is set; call mouse_position if you need to know where the cursor is.

The kwarg form filters by button too:

on(mouse_down: :left) { puts 'click!' }

on mouse_down: :right, mouse_held: :right do
  draw_continuously
end

:mouse_move, :mouse_scroll, :mouse_enter, and :mouse_leave don’t carry a matchable value, so use the basic form for those.

Polling

def update
  puts 'left click' if mouse_pressed? :left
  puts 'right released' if mouse_released? :right
  paint(mouse_x, mouse_y) if mouse_held? :left

  if mouse_scrolled?
    puts mouse_scroll_direction
    puts mouse_scroll_delta_x
    puts mouse_scroll_delta_y
  end

  if mouse_moved?
    puts mouse_move_delta_x
    puts mouse_move_delta_y
  end

  puts mouse_x
  puts mouse_y
  paint(mouse_x, mouse_y) if mouse_inside?   # cursor is over the window
end

Gamepads

Game controllers (gamepads) are automatically detected as they’re plugged in and unplugged. Each one shows up as a Gamepad object you can hold onto, query, and talk to directly.

window.gamepads is the list of pads currently connected, in the order they joined:

def update
  gamepads.each do |pad|
    # ...
  end
end

That makes single-pad games tidy: when no one’s plugged in, the loop just doesn’t run.

Connecting and disconnecting

Two events bracket a pad’s life. :gamepad_connect fires when one shows up (including once for each pad already plugged in when show is called), and :gamepad_disconnect fires when one is removed.

on :gamepad_connect do |pad|
  puts "#{pad.name} connected"
end

on :gamepad_disconnect do |pad|
  puts "#{pad.name} disconnected"
end

A reconnected pad is a new Gamepad object. Ruby 2D doesn’t try to guess that the device you just plugged back in is the same one you had before. The old object stays valid but reports connected? == false; calling held? or axis on it returns safe defaults rather than raising. That makes Gamepad instances safe to use as Hash keys — pin a player to a pad and the identity stays put for the life of the connection.

Buttons and axes

Face buttons are named by position, not by the labels printed on the pad. Picture the four face buttons as a compass: :south is the bottom button (A on Xbox, B on Nintendo, ✕ on PlayStation), :north is the top, :east is the right, :west is the left.

This sounds odd at first, but it lets your code work uniformly across every gamepad without caring which letter the manufacturer chose. If you’d rather use the label-style names, define your own constants:

A, B, X, Y = :south, :east, :west, :north

The full button set:

Group Names
Face :south, :east, :west, :north
Center :back, :guide, :start
Sticks :left_stick, :right_stick
Shoulders :left_shoulder, :right_shoulder
D-pad :dpad_up, :dpad_down, :dpad_left, :dpad_right
Extras :misc1, :paddle1..:paddle4, :touchpad

And the axes:

Axis Range
:left_x -1.0..1.0
:left_y -1.0..1.0
:right_x -1.0..1.0
:right_y -1.0..1.0
:left_trigger 0.0..1.0
:right_trigger 0.0..1.0

Not every pad has every button or axis. pad.has?(:button, :paddle1) or pad.has?(:axis, :left_trigger) will tell you for sure.

Event handlers

Gamepad buttons come in three flavors (down, held, and up) plus an axis event. Each block receives the originating Gamepad first, followed by whatever’s relevant:

on :gamepad_button_down do |pad, button|
  puts "#{pad.name} pressed #{button}"
end

on :gamepad_button_held do |pad, button|
  # ...
end

on :gamepad_button_up do |pad, button|
  # ...
end

on :gamepad_axis do |pad, axis, value|
  steer(pad, value) if axis == :left_x
end

The kwarg form filters by name:

on(gamepad_button_down: :south) { jump }

on gamepad_axis: :left_x do |pad, _, value|
  player.dx = value
end

Pass a hash when you want to filter on multiple fields at once — handy when you want events from a specific pad:

on gamepad_button_down: { gamepad: pad1, button: :south } do
  player1.jump
end

Polling

When you have a Gamepad in hand, ask it directly:

def update
  gamepads.each do |pad|
    player_for(pad).jump if pad.pressed? :south
    player_for(pad).dx = pad.axis(:left_x)
  end
end

The methods mirror the events: pad.pressed? is true on the frame the button was first pressed, pad.held? is true every frame it’s down, and pad.released? fires on the frame it lifts. pad.axis(:left_x) gives you the current value.

Asking about a button or axis the pad doesn’t have isn’t an error: held? returns false, axis returns 0.0. So you can poll freely without guarding every call.

Dead zones

Sticks are noisy at rest. Each pad applies a small dead zone to the sticks (0.05 by default), so tiny drifts read as zero and you don’t see phantom motion when no one’s touching the stick. It’s symmetric around zero, so anything inside ±0.05 reads as 0.0.

Triggers are exempt: they rest at zero and rarely drift, so the full 0.0..1.0 range stays usable.

pad.dead_zone        # => 0.05
pad.dead_zone = 0.0  # turn it off entirely

The dead zone applies to both events and polling. :gamepad_axis events deliver the dead-zoned value, and successive events with the same dead-zoned value are suppressed, so handlers don’t get spammed by motion entirely inside the dead zone. To bypass it for a one-off reading, pass raw: true:

pad.axis(:left_x, raw: true)

Rumble and LEDs

Pads that support haptics can rumble, and a few light up:

pad.rumble(strength: 0.5, duration: 0.2)
pad.rumble(low: 0.5, high: 1.0, duration: 0.2)
pad.rumble_triggers(left: 1.0, right: 0.0, duration: 0.1)
pad.led = [255, 0, 128]

The rumble calls are best-effort. If the pad doesn’t have haptics, or it’s been disconnected, they return false instead of raising. So you can wire up feedback without wrapping every call in a capability check, and reach for pad.has?(:rumble) only when you actually need to know. The pad.led = setter is a little different: like any Ruby assignment it evaluates to the value you assigned, not a success flag, so if you need to know whether the light actually changed, call pad.set_led([255, 0, 128]) and check its false-on-failure return.

Custom mappings

Ruby 2D ships with a generous library of gamepad mappings, but if you’ve got an unusual pad you can supply your own. Drop them in ~/.ruby2d/gamepads.txt. They’re loaded automatically when the window opens. To pull in extra files, or to register a mapping inline, call add_gamepad_mapping before show:

add_gamepad_mapping('/path/to/extras.txt')
add_gamepad_mapping('03000000...,My Pad,a:b0,b:b1,...')

The argument is smart: a path to an existing file gets loaded as a mapping file, anything else is treated as a single SDL mapping string. The format is the standard SDL gamepad mapping format, so the community SDL_GameControllerDB works as-is.

A few things to know

Pads are listed in the order they connected, not by player number. gamepads[0] is “first plugged in still present”, not “Player 1”. For stable player slots, hang onto the Gamepad you assigned and don’t expect the array index to mean anything.

Mappings are keyed by GUID, which is stable across reboots and machines for the same hardware. But the USB and Bluetooth versions of one pad have different GUIDs, and on macOS, renaming a pad in System Settings will invalidate any GUID-keyed mappings you wrote against it.

Beyond what we’ve seen, pad.id, pad.name, pad.type (:xbox, :playstation, :nintendo, :generic, or :unknown), pad.connected?, and pad.battery (:wired, :full, :medium, :low, :empty, or nil) round out what each pad can tell you about itself. If you ever need the full picture, pad.debug_info returns a hash with the GUID, USB vendor/product/version, real type, touchpad count, and the resolved mapping string, useful when something isn’t working as expected.

Managing event handlers

The on method returns an EventDescriptor that you can use to unregister the handler later with off:

handler = on :key_down do |event|
  puts event.key
end

# Later, when you no longer need it:
off handler

The kwarg form returns a single descriptor when there’s one filter, or an array of descriptors when you pass several. off accepts either.

This is useful for things like switching game states: register handlers for a menu, then remove them when gameplay starts.

Per-object events

Any renderable object can register its own event handlers for mouse interactions.

Object Event Description Filter by
:mouse_down Button pressed on the object button
:mouse_held Every frame while held — fires on the object originally pressed button
:mouse_up Button released — fires on the originally-pressed object button
:mouse_scroll Scroll wheel while hovering
:click Press and release on the same object button
:drag Mouse moved while pressed on the object button
:hover Mouse enters the object
:hover_out Mouse leaves the object

Events are dispatched to the topmost (highest z-order) interactive object at the mouse position. :mouse_held, :drag, and :mouse_up are sticky. Once a press starts on an object, those events belong to it: :mouse_held and :drag keep firing every frame until release, and :mouse_up always lands on the press target, even if the cursor wandered off in the meantime. If you happen to release over a different interactive object, that object also gets a :mouse_up. :click only fires when press and release land on the same object.

Example:

rect = Rectangle.new(x: 100, y: 100, width: 80, height: 80, color: 'blue')

rect.on :click do |event|
  puts "Clicked at (#{event.x}, #{event.y})"
end

rect.on :hover do |event|
  rect.color = 'yellow'
end

rect.on :hover_out do |event|
  rect.color = 'blue'
end

rect.on :mouse_down do |event|
  puts "Mouse down: #{event.button}"
end

rect.on :mouse_held do |event|
  rect.color = pulse(rect.color)  # keeps pulsing while held
end

rect.on :mouse_up do |event|
  puts "Mouse up"
end

rect.on :drag do |event|
  rect.x += event.delta_x
  rect.y += event.delta_y
end

rect.on :mouse_scroll do |event|
  puts "Scrolled #{event.direction}"
end

The kwarg form of on works on per-object handlers too, same shape as the window-level version:

rect.on(click: :left) { open }                       # filter by button
rect.on click: [:left, :right] do                    # array → match any
  flash
end
rect.on mouse_down: :right, click: :right do         # multi-event
  show_menu
end

:hover, :hover_out, and :mouse_scroll don’t carry a button to filter on, so use the basic form for those.

Remove a per-object handler:

handler = rect.on(:click) { puts 'clicked' }
rect.off(handler)

And check if an object has handlers:

rect.interactive?          # any handlers at all?
rect.interactive?(:click)  # handlers for :click specifically?

Continue to the next topic


Suggest an edit to this page