Game Development with Godot & GDScript Module 5 – Input and player control
Mouse, touch, gamepads and key rebinding
Aim at the mouse under a moving Godot 4.7 camera, read touches, gamepads and the new VirtualJoystick, let players rebind keys and pause when focus is lost.
What you will learn
- Convert mouse and touch positions between viewport, world and local coordinates
- Support touch, gamepads and vibration, and set up the VirtualJoystick node of Godot 4.7
- Build a rebinding service that refuses keys already taken and saves the bindings as plain data
- Handle focus loss by pausing the game, and ignore controller input while the game is unfocused
Before you start
What you’ll learn · Before you start
What you will learn
- Convert mouse and touch positions between viewport, world and local coordinates
- Support touch, gamepads and vibration, and set up the VirtualJoystick node of Godot 4.7
- Build a rebinding service that refuses keys already taken and saves the bindings as plain data
- Handle focus loss by pausing the game, and ignore controller input while the game is unfocused
Before you start
On this page
A turret that aimed true at the start of a level turned 175.9 degrees, nearly backwards, once the camera had followed the player 1,424 px to the right and zoomed in. Nothing about the mouse had changed: the code read a position in the window and treated it as a position in the world.
That is the first of the ways in which devices beyond the keyboard catch games out. This page converts mouse and touch positions between the window, the world and a node; reads every finger of a touch, the VirtualJoystick of Godot 4.7 and gamepads with their device numbers and vibration; builds a rebinding service that refuses keys already taken and saves the bindings as plain numbers; and pauses the game when its window loses the focus. The runs use Godot 4.7.2 with no window at all, and code plays the part of the mouse, the fingers and the pads.
A mouse position means nothing until you know its space
Two turrets turn towards the mouse. RightTurret looks at get_global_mouse_position(); WrongTurret looks at
event.position, the position the mouse event carries. The mouse stops at the same point of the 1152 × 648 window
twice: before the player has walked, and after the camera has followed them 1,424 px to the right and zoomed to 2.
The examples hold the headless root window at the project’s 1152 × 648 with root.min_size, so their positions are
those of a game window:
extends SceneTree
# Two turrets turn towards the mouse under a Camera2D: RightTurret looks at get_global_mouse_position(), WrongTurret
# at the event's own position. The mouse stops at the same point of the window twice: before the player has walked,
# with the camera showing the world at its original size, and after, with the camera 1,424 px further right and
# zoomed in twice.
# Run it with --fixed-fps 60, so that every frame has exactly one physics tick.
class RightTurret extends Node2D:
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseMotion:
print(" event.position %s, get_global_mouse_position() %s" % [event.position, get_global_mouse_position()])
print(" get_local_mouse_position() %s, make_input_local(event).position %s" % [
get_local_mouse_position(), make_input_local(event).position])
look_at(get_global_mouse_position())
class WrongTurret extends Node2D:
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseMotion:
look_at(event.position)
var tick := 0
var camera := Camera2D.new()
var right := RightTurret.new()
var wrong := WrongTurret.new()
func _initialize() -> void:
# A headless run has no real window: hold the root at the project's window size, as a game window would be.
root.min_size = Vector2i(ProjectSettings.get_setting("display/window/size/viewport_width"),
ProjectSettings.get_setting("display/window/size/viewport_height"))
print("Window: %d x %d" % [root.min_size.x, root.min_size.y])
for node: Node in [camera, right, wrong]:
root.add_child(node)
place(Vector2(576, 324), 1.0, Vector2(376, 224))
func _physics_process(_delta: float) -> bool:
tick += 1
match tick:
3, 7:
print(" RightTurret turned to %.1f degrees, WrongTurret to %.1f degrees" % [
right.rotation_degrees, wrong.rotation_degrees])
4:
# The player walked right: the camera follows, 1,424 px further, and zooms in twice.
place(Vector2(2000, 400), 2.0, Vector2(1900, 350))
8:
return true
return false
# After each frame, like a player between two frames: move the mouse to (876, 424) in the window before ticks 3 and 7.
func _process(_delta: float) -> bool:
if tick + 1 == 3 or tick + 1 == 7:
var event := InputEventMouseMotion.new()
event.position = Vector2(876, 424)
event.global_position = event.position
Input.parse_input_event(event)
return false
func place(at: Vector2, zoom: float, turret_at: Vector2) -> void:
camera.position = at
camera.zoom = Vector2(zoom, zoom)
for turret: Node2D in [right, wrong]:
turret.position = turret_at
turret.rotation = 0.0
print("Camera at %s, zoom %s; both turrets at %s" % [at, zoom, turret_at]) from session import files, run
files("project.godot", "aim_at_mouse.gd")
run("""
godot --headless --no-header --fixed-fps 60 --script aim_at_mouse.gd
""") Output
$ godot --headless --no-header --fixed-fps 60 --script aim_at_mouse.gd Window: 1152 x 648 Camera at (576.0, 324.0), zoom 1.0; both turrets at (376.0, 224.0) event.position (876.0, 424.0), get_global_mouse_position() (876.0, 424.0) get_local_mouse_position() (500.0, 200.0), make_input_local(event).position (500.0, 200.0) RightTurret turned to 21.8 degrees, WrongTurret to 21.8 degrees Camera at (2000.0, 400.0), zoom 2.0; both turrets at (1900.0, 350.0) event.position (876.0, 424.0), get_global_mouse_position() (2150.0, 450.0) get_local_mouse_position() (250.0, 100.0), make_input_local(event).position (250.0, 100.0) RightTurret turned to 21.8 degrees, WrongTurret to 175.9 degrees
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 aim_session.py
The first time, both turrets turned to 21.8 degrees. The second time, RightTurret turned to 21.8 degrees again, since
the turret and the mouse were in the same places on the screen, and WrongTurret turned to 175.9. The reason is the
space each number lives in. In _input() and _unhandled_input(), a mouse event’s position is in the viewport’s
coordinates, as the InputEventMouse reference says: pixels of the window, whatever the camera shows. A Camera2D makes
the screen follow it by changing the canvas transform: The transform that turns world positions on a canvas into positions in the viewport, the window's pixels. A Camera2D changes it as it moves and zooms, so get_canvas_transform().affine_inverse() turns a window position into a world position., which moves and scales the
world before it is drawn. While the camera showed the world from (0, 0) at zoom 1, window and world agreed, and the
bug was invisible. Step through the second stop of the mouse:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The window point. The mouse stops at (876, 424) in the 1152 x 648 window. That is event.position in _unhandled_input(), a point in the viewport, which says nothing yet about where it is in the world.
- Before the player walks. The camera at (576, 324) with zoom 1 shows the world from (0, 0), so the window point and the world point are the same. Both turrets, at (376, 224), turned to 21.8 degrees.
- The camera moves. The player walks right. The camera follows to (2000, 400) and zooms to 2, and with it the canvas transform, which now moves the world by (-3424, -476) and scales it by 2 before it is drawn.
- Back into the world. get_global_mouse_position() undoes the canvas transform. (876 + 3424) / 2 = 2150 and (424 + 476) / 2 = 450, so the mouse is at (2150, 450) in the world.
- Seen from the turret. The turret stands at (1900, 350), so in its own space the mouse is at (250, 100), as get_local_mouse_position() printed, at 21.8 degrees. RightTurret turned there.
- The shortcut. WrongTurret took (876, 424) as a world point. In the world that point lies far behind it, at x = 876 against its 1900, so it turned to 175.9 degrees.
Three calls convert for you. get_global_mouse_position() gives the mouse in the world, “relative to the CanvasLayer
that contains this node”, as the CanvasItem reference puts it; get_local_mouse_position() gives it in the node’s own
space, (250, 100) from the turret; and make_input_local(event) returns a copy of the event with its position in the
node’s space, (250, 100) again. By hand, get_canvas_transform().affine_inverse() * event.position gives the world
point. A touch event’s position is in the viewport’s coordinates as well, so convert a finger’s position with
make_input_local(event) or the inverse canvas transform; the two mouse calls know nothing of a second finger.
Aiming at event.position
look_at(event.position) aims at a point of the window as if it were a point of the world. It works only while the
camera shows the world from its origin at zoom 1, which is often the first room of a level, where nobody notices.
Use look_at(get_global_mouse_position()).
Checkpoint
Check: Window, world and local positions
Each finger is its own event, and the first one also moves the mouse
A touch screen sends InputEventScreenTouch when a finger goes down or up and InputEventScreenDrag while it moves,
each with the index of its finger. In this run two fingers touch, the first drags 30 px, and both lift, while a node
prints every event it receives; then one finger drags the tip of a VirtualJoystick:
extends SceneTree
# Part 1: two fingers touch the screen, the first one drags, and both lift; a node prints every event it receives.
# Part 2: one finger drags the tip of a VirtualJoystick (new in Godot 4.7, 100 px across) 25 px and then 50 px to the
# right, first with its default ui_ actions and then with the project's move actions.
# Run it with --fixed-fps 60, so that every frame has exactly one physics tick.
const STEPS := [
["finger 0 touches", "touch", 0, Vector2(100, 500), true],
["finger 1 touches", "touch", 1, Vector2(900, 300), true],
["finger 0 drags 30 px right", "drag", 0, Vector2(130, 500), true],
["both fingers lift", "touch", 0, Vector2(130, 500), false],
["", "touch", 1, Vector2(900, 300), false],
]
class Spy extends Node:
func _unhandled_input(event: InputEvent) -> void:
var kind := event.get_class().trim_prefix("InputEvent")
if event is InputEventScreenTouch or event is InputEventMouseButton:
kind += " down" if event.pressed else " up"
if event is InputEventScreenTouch or event is InputEventScreenDrag:
print(" %s, finger %d at %s, device %d" % [kind, event.index, event.position, event.device])
elif event is InputEventMouse:
print(" %s at %s, device %d" % [kind, event.position, event.device])
var tick := 0
var spy := Spy.new()
var stick := VirtualJoystick.new()
func _initialize() -> void:
# A headless run has no real window: hold the root at the project's window size, as a game window would be.
root.min_size = Vector2i(ProjectSettings.get_setting("display/window/size/viewport_width"),
ProjectSettings.get_setting("display/window/size/viewport_height"))
root.add_child(spy)
stick.position = Vector2(40, 400)
stick.size = Vector2(200, 200)
stick.released.connect(func(direction: Vector2) -> void: print(" the finger lifts: the released signal brings %s" % direction))
print("emulate_mouse_from_touch %s, emulate_touch_from_mouse %s" % [
ProjectSettings.get_setting("input_devices/pointing/emulate_mouse_from_touch"),
ProjectSettings.get_setting("input_devices/pointing/emulate_touch_from_mouse")])
func _physics_process(_delta: float) -> bool:
tick += 1
if tick == 8:
# Part 2: the spy goes, and the joystick comes, on a CanvasLayer at the bottom left.
spy.queue_free()
var layer := CanvasLayer.new()
root.add_child(layer)
layer.add_child(stick)
print("VirtualJoystick: mode %d (fixed), %d px across, deadzone_ratio %.1f" % [
stick.joystick_mode, stick.joystick_size, stick.deadzone_ratio])
for round in 2:
var start := 10 + round * 10
var actions: Array = ["ui_left", "ui_right", "ui_up", "ui_down"] if round == 0 else ["move_left", "move_right", "move_up", "move_down"]
if tick == start:
stick.action_left = actions[0]
stick.action_right = actions[1]
stick.action_up = actions[2]
stick.action_down = actions[3]
print("Actions %s, deadzone %.1f:" % [", ".join(actions), InputMap.action_get_deadzone(actions[1])])
if tick == start + 3 or tick == start + 5:
print(" tip %d px right: %s strength %.2f, get_vector() %s" % [
25 if tick == start + 3 else 50, actions[1], Input.get_action_strength(actions[1]),
Input.get_vector(actions[0], actions[1], actions[2], actions[3])])
if tick == start + 7:
print(" after the lift: %s pressed %s" % [actions[1], Input.is_action_pressed(actions[1])])
if tick == 30:
return true
return false
# After each frame, like a player between two frames: the touches of part 1 before ticks 2 to 6, then in each round
# of part 2 a touch at the centre of the joystick, a drag to 25 px and to 50 px, and a lift.
func _process(_delta: float) -> bool:
var next := tick + 1
if next >= 2 and next < 2 + STEPS.size():
var step: Array = STEPS[next - 2]
if step[0] != "":
print(step[0] + ":")
send(step[1], step[2], step[3], step[4])
for round in 2:
var start := 10 + round * 10
var centre := Vector2(140, 500)
match next - start:
1:
send("touch", 0, centre, true)
2:
send("drag", 0, centre + Vector2(25, 0), true)
4:
send("drag", 0, centre + Vector2(50, 0), true)
6:
send("touch", 0, centre + Vector2(50, 0), false)
return false
func send(kind: String, finger: int, at: Vector2, down: bool) -> void:
var event: InputEvent
if kind == "touch":
var touch := InputEventScreenTouch.new()
touch.pressed = down
event = touch
else:
var drag := InputEventScreenDrag.new()
event = drag
event.index = finger
event.position = at
Input.parse_input_event(event) from session import files, run
files("project.godot", "touch_and_stick.gd")
run("""
godot --headless --no-header --fixed-fps 60 --script touch_and_stick.gd
""") Output
$ godot --headless --no-header --fixed-fps 60 --script touch_and_stick.gd emulate_mouse_from_touch true, emulate_touch_from_mouse false finger 0 touches: MouseButton down at (100.0, 500.0), device -1 ScreenTouch down, finger 0 at (100.0, 500.0), device 0 finger 1 touches: ScreenTouch down, finger 1 at (900.0, 300.0), device 0 finger 0 drags 30 px right: MouseMotion at (130.0, 500.0), device -1 ScreenDrag, finger 0 at (130.0, 500.0), device 0 both fingers lift: MouseButton up at (130.0, 500.0), device -1 ScreenTouch up, finger 0 at (130.0, 500.0), device 0 ScreenTouch up, finger 1 at (900.0, 300.0), device 0 VirtualJoystick: mode 0 (fixed), 100 px across, deadzone_ratio 0.0 Actions ui_left, ui_right, ui_up, ui_down, deadzone 0.5: tip 25 px right: ui_right strength 0.50, get_vector() (0.0, 0.0) tip 50 px right: ui_right strength 1.00, get_vector() (1.0, 0.0) the finger lifts: the released signal brings (1.0, 0.0) after the lift: ui_right pressed false Actions move_left, move_right, move_up, move_down, deadzone 0.2: tip 25 px right: move_right strength 0.50, get_vector() (0.375, 0.0) tip 50 px right: move_right strength 1.00, get_vector() (1.0, 0.0) the finger lifts: the released signal brings (1.0, 0.0) after the lift: move_right pressed false
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 touch_session.py
Every event of finger 0 arrived twice: as a touch or a drag, and just before it as a mouse button or a mouse motion
with device -1. Finger 1 arrived only as touches. The project setting input_devices/pointing/emulate_mouse_from_touch was true, its default, and the
Input reference says it sends mouse input events “when tapping or swiping on the touchscreen”: in the run, for the
first finger only, so a game written for the mouse also works on a phone. Its partner emulate_touch_from_mouse was
false; turned on, it makes mouse clicks and motion arrive as touches, which lets you try touch code at a desk (Input
examples). Device -1 is InputEvent.DEVICE_ID_EMULATION, which the InputEvent reference keeps for such emulated
events, so code that must not react twice can skip them.
Godot 4.7 added the VirtualJoystick: A Control node, new in Godot 4.7, that draws a stick on a touch screen and presses four actions, ui_left to ui_down unless changed, as its tip is dragged. It can stay fixed, move to the first touch or also follow the finger. node, a Control that draws a stick on the screen and presses four actions as its tip is dragged. Its reference gives it three modes: Fixed, where it never moves; Dynamic, where it moves to the first touch inside its area; and Following, where it also follows a finger that leaves its range. Follow the run’s drag on a joystick of the default size:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Touch. The finger touches the centre of the joystick. The joystick is a Control, so the touch goes to it, and from here its tip moves with the finger.
- Half way. The finger drags the tip 25 px to the right, half the radius of 50. The joystick presses its right action at strength 0.5.
- With the ui_ actions. By default the joystick presses ui_right, whose deadzone is 0.5. get_vector() over the four ui_ actions met a length of 0.5, no more than the deadzone, and returned (0, 0).
- With the move actions. In the second round the joystick presses move_right, deadzone 0.2. The same 25 px gave get_vector() (0.375, 0), the value of a half-tilted stick in the lesson before.
- The edge. At 50 px the tip reaches the edge of the joystick. The strength is 1.0, and both sets of actions give (1, 0).
- Lift. The finger lifts. The joystick emits released with the last direction, (1, 0), and lets go of its action, which is no longer pressed.
A 25 px drag on the 100 px joystick pressed the right action at strength 0.5. With the joystick’s default actions,
ui_left to ui_down, whose deadzone is 0.5, get_vector() returned (0, 0): half a tilt did nothing. With the
project’s move actions, deadzone 0.2, the same drag gave (0.375, 0), the value a half-tilted stick gave in the lesson
before.
Leaving the VirtualJoystick on the ui_ actions
Its action_left, action_right, action_up and action_down start as the menus’ ui_ actions, whose deadzone of
0.5 swallows every drag up to half the radius in get_vector(). Point them at the game’s own actions, and leave the
ui_ ones to the menus.
Checkpoint
Check: Touch events, emulation and the VirtualJoystick
A gamepad event says which pad sent it
Every joypad event carries a device id: The number in InputEvent.device that says which device sent an event: 0, 1 and so on for gamepads, in the order Godot found them, and -1 for emulated mouse and touch events. An Input Map event with device -1 matches every device.: 0 for the first pad Godot found, 1 for the second,
and so on. This run presses the bottom face button, JOY_BUTTON_A, on pad 1 and then on pad 0, and checks three
actions: jump from the Input Map, and p1_jump and p2_jump, which the script adds for pad 0 and pad 1 only. Then
it asks pad 0 to vibrate:
extends SceneTree
# Two gamepads press their bottom button, JOY_BUTTON_A, in turn. jump comes from the project's Input Map, whose
# events have device -1, "all devices"; p1_jump and p2_jump are added here for pad 0 and pad 1 only. Then pad 0 is
# asked to vibrate. No controller is connected to the computer that ran this: the presses are simulated events.
# Run it with --fixed-fps 60, so that every frame has exactly one physics tick.
var tick := 0
func _initialize() -> void:
print("connected pads: ", Input.get_connected_joypads())
print("jump's button event is for device ", InputMap.action_get_events("jump")[1].device)
for player in 2:
var action := "p%d_jump" % (player + 1)
InputMap.add_action(action)
var button := InputEventJoypadButton.new()
button.button_index = JOY_BUTTON_A
button.device = player
InputMap.action_add_event(action, button)
func _physics_process(_delta: float) -> bool:
tick += 1
if tick == 3 or tick == 6:
print("pad %d holds A: jump %s, p1_jump %s, p2_jump %s" % [1 if tick == 3 else 0,
Input.is_action_pressed("jump"), Input.is_action_pressed("p1_jump"), Input.is_action_pressed("p2_jump")])
if tick == 8:
Input.start_joy_vibration(0, 0.4, 0.8, 0.25)
print("pad 0 vibration: weak and strong motor %s, for %.2f s" % [
Input.get_joy_vibration_strength(0), Input.get_joy_vibration_duration(0)])
Input.stop_joy_vibration(0)
print("after stop_joy_vibration(0): %s" % Input.get_joy_vibration_strength(0))
return true
return false
# After each frame, like a player between two frames: pad 1 presses A before tick 3 and lets go before tick 4, then
# pad 0 presses A before tick 6 and lets go before tick 7.
func _process(_delta: float) -> bool:
match tick + 1:
3, 4:
button(1, tick + 1 == 3)
6, 7:
button(0, tick + 1 == 6)
return false
func button(pad: int, down: bool) -> void:
var event := InputEventJoypadButton.new()
event.device = pad
event.button_index = JOY_BUTTON_A
event.pressed = down
Input.parse_input_event(event) from session import files, run
files("project.godot", "pads.gd")
run("""
godot --headless --no-header --fixed-fps 60 --script pads.gd
""") Output
$ godot --headless --no-header --fixed-fps 60 --script pads.gd connected pads: [] jump's button event is for device -1 pad 1 holds A: jump true, p1_jump false, p2_jump true pad 0 holds A: jump true, p1_jump true, p2_jump false pad 0 vibration: weak and strong motor (0.4, 0.8), for 0.25 s after stop_joy_vibration(0): (0.0, 0.0)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pads_session.py
The Input Map’s jump event has device -1, and it matched both pads. p1_jump answered pad 0 alone and p2_jump
pad 1 alone. The run, side by side:
| Pressed | jump, device -1 |
p1_jump, device 0 |
p2_jump, device 1 |
|---|---|---|---|
| A on pad 1 | true | false | true |
| A on pad 0 | true | true | false |
So in a game for two players on one computer, both heroes jump at either player’s press until each player has actions
of their own, bound to their device, or code that reads the event’s device in _unhandled_input(). The Input
reference warns that the order of connected pads “can not be guaranteed to be the same after a project and/or the
editor is restarted”, so let each player claim a pad by pressing a button on it, and listen to the
joy_connection_changed signal for pads that are plugged in or out while the game runs. Input.get_connected_joypads()
lists the pads; it was empty here, because the run had no controller connected, and every press was a simulated
event.
Vibration takes a pad, the strengths of its weak and strong motors from 0 to 1, and a duration in seconds:
Input.start_joy_vibration(0, 0.4, 0.8, 0.25). Godot kept those values, (0.4, 0.8) for 0.25 s, even with no pad to
shake, and stop_joy_vibration() set them back to 0. A phone vibrates with Input.vibrate_handheld(), which on
Android needs the VIBRATE permission in the export preset, and the Controllers page suggests a setting that lets
players turn vibration down or off. Since 4.7 Godot can also read the gyroscope and accelerometer of controllers that
have them, on Windows, Linux, macOS and iOS, after Input.set_joy_motion_sensors_enabled(): the sensors start
disabled, to save the controller’s battery.
Checkpoint
Check: Gamepad devices and vibration
Rebinding swaps one event for another and saves one number
Godot keeps changes to the Input Map only while the game runs: the Using InputEvent page says the InputMap singleton “is not saved (must be modified manually)”. rebinding: Letting players choose the keys and buttons of the game's actions. The game replaces events with InputMap.action_erase_event() and action_add_event(), and saves the choices in a file of its own, because Godot does not save the InputMap. is therefore two jobs, changing the InputMap and saving the player’s choice in a file of the game’s own. This run gives jump the key K in place of Space, saves the keys, puts the Input Map back to the project’s defaults, and loads the file:
extends SceneTree
# A rebinding screen in miniature. The player chooses jump and presses K; the new key goes into the Input Map in place
# of Space and is saved to user://input.cfg as a number. Then the Input Map goes back to the project's defaults, and
# loading the file brings K back.
# Run it with --fixed-fps 60, so that every frame has exactly one physics tick.
const ACTIONS := ["move_left", "move_right", "move_up", "move_down", "jump"]
const PATH := "user://input.cfg"
# Waits for the next key press and gives it to the chosen action, in place of the action's old key.
class Rebinder extends Node:
var waiting_for := ""
func _input(event: InputEvent) -> void:
if waiting_for == "" or not event is InputEventKey or not event.pressed or event.echo:
return
set_key(waiting_for, event.physical_keycode)
print(" %s now has the key in the place of %s" % [waiting_for, OS.get_keycode_string(event.physical_keycode)])
waiting_for = ""
# The press chose a key; it is not also a press for the game.
get_viewport().set_input_as_handled()
# Gives the action this key in place of its old key; its joypad events stay.
static func set_key(action: String, physical: Key) -> void:
for old in InputMap.action_get_events(action):
if old is InputEventKey:
InputMap.action_erase_event(action, old)
var key := InputEventKey.new()
key.physical_keycode = physical
key.device = -1 # all devices, as the Input Map of the editor saves its events
InputMap.action_add_event(action, key)
var tick := 0
var rebinder := Rebinder.new()
func _initialize() -> void:
root.add_child(rebinder)
show("jump")
print("The player chooses jump and presses K:")
rebinder.waiting_for = "jump"
func _physics_process(_delta: float) -> bool:
tick += 1
if tick == 4:
show("jump")
save_keys()
print("%s holds:\n%s" % [PATH, FileAccess.get_file_as_string(PATH).strip_edges()])
var as_object := var_to_str(InputMap.action_get_events("jump")[1])
print("The same key as an InputEventKey object would be %d characters: %s..." % [as_object.length(),
as_object.left(56)])
InputMap.load_from_project_settings()
print("After InputMap.load_from_project_settings():")
show("jump")
load_keys()
print("After loading %s:" % PATH)
show("jump")
return true
return false
# After each frame, like a player between two frames: press K before tick 2 and let go before tick 3.
func _process(_delta: float) -> bool:
if tick + 1 == 2 or tick + 1 == 3:
var event := InputEventKey.new()
event.keycode = KEY_K
event.physical_keycode = KEY_K
event.pressed = tick + 1 == 2
Input.parse_input_event(event)
return false
func save_keys() -> void:
var file := ConfigFile.new()
for action: String in ACTIONS:
for event in InputMap.action_get_events(action):
if event is InputEventKey:
file.set_value("keys", action, event.physical_keycode)
file.save(PATH)
# Reads the keys back, taking only whole numbers for actions that exist: the player can edit this file.
func load_keys() -> void:
var file := ConfigFile.new()
if file.load(PATH) != OK:
return
for action: String in ACTIONS:
var code: Variant = file.get_value("keys", action, -1)
if code is int and code > 0:
Rebinder.set_key(action, code)
func show(action: String) -> void:
var names := PackedStringArray()
for event in InputMap.action_get_events(action):
if event is InputEventKey:
names.append("key %s" % OS.get_keycode_string(event.physical_keycode))
elif event is InputEventJoypadButton:
names.append("joypad button %d" % event.button_index)
print(" %s: %s" % [action, ", ".join(names)]) from session import files, run
files("project.godot", "rebind_keys.gd")
run("""
godot --headless --no-header --fixed-fps 60 --script rebind_keys.gd
""") Output
$ godot --headless --no-header --fixed-fps 60 --script rebind_keys.gd jump: key Space, joypad button 0 The player chooses jump and presses K: jump now has the key in the place of K jump: joypad button 0, key K user://input.cfg holds: [keys] move_left=65 move_right=68 move_up=87 move_down=83 jump=75 The same key as an InputEventKey object would be 299 characters: Object(InputEventKey,"resource_local_to_scene":false,"re... After InputMap.load_from_project_settings(): jump: key Space, joypad button 0 After loading user://input.cfg: jump: joypad button 0, key K
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 rebind_session.py
The Rebinder waits for the next key in _input(), so that it gets the press before the UI and the game, and marks the
event as handled, so that the press does nothing else. It keeps only the key’s place, its physical keycode: 75 for K.
InputMap.action_erase_event() then takes Space off jump and InputMap.action_add_event() adds K; the gamepad button
stays, because only the key was replaced, where InputMap.action_erase_events() would have taken every event of the
action, the button too. Step through the rebinding:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- jump is chosen. The player picks jump on the rebinding screen, and the Rebinder waits for the next key press.
- K is pressed. _input() sees K before the UI and the game. The Rebinder keeps its physical keycode, 75, and marks the event as handled, so the press does nothing else.
- Is K taken?. No other action uses K, so jump may have it. In the practice, K then offered to move_left was refused with K already drives jump, and the screen went back to waiting.
- The swap. action_erase_event() takes Space off jump and action_add_event() adds K, while jump's gamepad button stays. The run printed jump with joypad button 0 and key K.
- Saved as numbers. The file holds jump=75 under [keys], one number per action. The same key saved as an InputEventKey object took 299 characters.
- Loaded again. InputMap.load_from_project_settings() put back Space, the project's default. Loading the file, which takes only whole numbers for actions the game has, gave jump its K again.
The file holds plain numbers, jump=75 under [keys]. ConfigFile could store the whole InputEventKey object
instead, but that took 299 characters per key, with every property of the event, and a file that a player can open in
a text editor is better kept to data that the game can check: load_keys() takes a value only when it is a whole
number above 0 for an action the game has. InputMap.load_from_project_settings() works as a Reset to defaults
button, because it reloads the project’s actions: the run showed Space back on jump until the file was loaded.
Key idea
Rebinding replaces events in the InputMap, which Godot never saves, so the game saves the player’s keys and buttons itself, as plain numbers that it checks when it loads them.
Checkpoint
Check: Rebinding and saving the bindings
Losing the window’s focus should pause the game, not feed it
A player who switches to another program in the middle of a fight expects to find the fight where they left it. The
Node reference lists NOTIFICATION_APPLICATION_FOCUS_OUT, sent when the application loses the focus, and
NOTIFICATION_APPLICATION_FOCUS_IN, sent when it gets it back, on desktop and mobile platforms. This run pauses on the
first and, on the second, waits for the player to press Escape. A headless run has no window to lose, so the script
sends the two notifications itself, through the SceneTree, which passes them on to every node:
extends SceneTree
# The game pauses when the player switches to another program, and stays paused when they come back, until they
# press Escape. Godot's main loop receives NOTIFICATION_APPLICATION_FOCUS_OUT and _IN from the operating system and
# passes them to every node; a headless run has no window to lose, so this script sends the two notifications itself,
# down the same path.
# Run it with --fixed-fps 60, so that every frame has exactly one physics tick.
# The key presses of the run, by the tick they come before.
const KEYS := {
2: ["Space, while the game runs:", KEY_SPACE, true], 3: ["", KEY_SPACE, false],
5: ["Space, while the player is away:", KEY_SPACE, true], 6: ["", KEY_SPACE, false],
7: ["Space, back in the game:", KEY_SPACE, true], 8: ["", KEY_SPACE, false],
9: ["Escape:", KEY_ESCAPE, true], 10: ["", KEY_ESCAPE, false],
11: ["Space, after resuming:", KEY_SPACE, true], 12: ["", KEY_SPACE, false],
}
class Game extends Node:
func _ready() -> void:
# Keeps listening while the tree is paused.
process_mode = Node.PROCESS_MODE_ALWAYS
func _notification(what: int) -> void:
if what == NOTIFICATION_APPLICATION_FOCUS_OUT and not get_tree().paused:
get_tree().paused = true
print(" Game: the window lost the focus, so the game pauses")
elif what == NOTIFICATION_APPLICATION_FOCUS_IN:
print(" Game: the focus is back; the game stays paused until the player resumes")
func _unhandled_input(event: InputEvent) -> void:
if get_tree().paused and event.is_action_pressed("ui_cancel"):
get_tree().paused = false
print(" Game: Escape, the game resumes")
class Hero extends Node:
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("jump"):
print(" Hero jumps")
var tick := 0
func _initialize() -> void:
print("input_devices/joypads/ignore_joypad_on_unfocused_application: ",
ProjectSettings.get_setting("input_devices/joypads/ignore_joypad_on_unfocused_application"))
Input.ignore_joypad_on_unfocused_application = true
print("Input.ignore_joypad_on_unfocused_application, set by the game: ", Input.ignore_joypad_on_unfocused_application)
root.add_child(Game.new())
root.add_child(Hero.new())
func _physics_process(_delta: float) -> bool:
tick += 1
match tick:
3:
print("The player switches to another program:")
notification(NOTIFICATION_APPLICATION_FOCUS_OUT)
6:
print("The player comes back:")
notification(NOTIFICATION_APPLICATION_FOCUS_IN)
12:
print("Paused at the end: %s" % paused)
return true
return false
# After each frame, like a player between two frames, send the key press of the next tick.
func _process(_delta: float) -> bool:
if KEYS.has(tick + 1):
var step: Array = KEYS[tick + 1]
if step[0] != "":
print(step[0])
var event := InputEventKey.new()
event.keycode = step[1]
event.physical_keycode = step[1]
event.pressed = step[2]
Input.parse_input_event(event)
return false from session import files, run
files("project.godot", "focus_pause.gd")
run("""
godot --headless --no-header --fixed-fps 60 --script focus_pause.gd
""") Output
$ godot --headless --no-header --fixed-fps 60 --script focus_pause.gd input_devices/joypads/ignore_joypad_on_unfocused_application: false Input.ignore_joypad_on_unfocused_application, set by the game: true Space, while the game runs: Hero jumps The player switches to another program: Game: the window lost the focus, so the game pauses Space, while the player is away: The player comes back: Game: the focus is back; the game stays paused until the player resumes Space, back in the game: Escape: Game: Escape, the game resumes Space, after resuming: Hero jumps Paused at the end: false
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 focus_session.py
While the game was paused, Space did nothing. The Pausing games page says _process, _physics_process and _input
are not called on paused nodes, and in the run the Hero’s _unhandled_input() was not called either. Game went on
working because its process mode is Always. Follow the run:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Playing. While the game runs, Space reaches the Hero's _unhandled_input(), and the Hero jumps.
- Focus lost. The player switches to another program. NOTIFICATION_APPLICATION_FOCUS_OUT reaches every node, and Game sets get_tree().paused to true.
- Away. Space while the player is away does nothing. The Hero is paused, so its _unhandled_input() is not called.
- Back. The player comes back. Game hears NOTIFICATION_APPLICATION_FOCUS_IN and leaves the game paused, so the player decides when it goes on.
- Resume. Space still does nothing. Escape reaches Game, which runs even while the tree is paused, because its process mode is Always; it unpauses the game.
- Playing again. Space reaches the Hero again, and it jumps. The run ended with the game unpaused.
Gamepads need one more step. The Controllers page warns that controller input “can by default be seen by all windows
on the operating system, including unfocused windows”, so a player using a pad in another program may steer your game
as well. Godot 4.7 added the project setting input_devices/joypads/ignore_joypad_on_unfocused_application, false by
default as the run printed, and the same switch at run time, Input.ignore_joypad_on_unfocused_application. With it
on, the Input reference says joypad input is ignored and vibration stops while the application is not focused.
Resume on the player's word, not on the focus
Coming back to the window is not a request to play. Keep the game paused on NOTIFICATION_APPLICATION_FOCUS_IN, and
let the pause menu’s Resume, or Escape, unpause it, as the run’s Game does.
Checkpoint
Check: Focus loss and unfocused gamepads
Practise: a rebinding service that refuses keys already taken
Write the service behind a settings screen. rebinder.gd has three functions to finish: rebind() gives an action a
new key or gamepad button and refuses one that another action already has, save_bindings() writes every action’s key
and button as numbers, and load_bindings() reads them back, skipping anything that is not a whole number in range or
that names no action of the game.
- In a Godot 4.7 project with the five actions of the lesson before,
move_left,move_right,move_upandmove_downon the places of A, D, W and S and the left stick, andjumpon Space and joypad button 0, saverebinder.gdandcheck_rebinder.gdfrom below. - Finish the three functions. A refusal returns a sentence that names the action that already has the key or button.
- In a terminal in the project’s folder, run
godot --headless --no-header --script check_rebinder.gd.
extends RefCounted
# Rebinds the keys and gamepad buttons of the game's actions, refuses one that another action already uses, and saves
# the bindings to a file as plain numbers.
const ACTIONS := ["move_left", "move_right", "move_up", "move_down", "jump"]
## Binds a key press or a gamepad button press to action, in place of the action's own key or button. Returns "" when
## it worked, or why not.
func rebind(action: String, event: InputEvent) -> String:
# TODO: refuse anything but a key or a gamepad button, and a key or button that another action already has.
# TODO: take the action's old key (or button) off, and add the new one.
return "not written yet"
## Writes the key and the button of every action to path, as numbers.
func save_bindings(path: String) -> Error:
# TODO: a ConfigFile with a "keys" section (physical keycodes) and a "buttons" section (button indexes).
return ERR_UNAVAILABLE
## Reads path and applies its bindings. The player can edit the file, so an entry that is not a whole number in range,
## or that names no action of the game, is skipped.
func load_bindings(path: String) -> Error:
# TODO: read the file, check every value, take off the keys and buttons it replaces, then bind the new ones.
return ERR_UNAVAILABLE extends SceneTree
# Checks the rebinding service of the practice. Run it in the project's folder:
# godot --headless --no-header --script check_rebinder.gd
# It rebinds with key and button presses as a rebinding screen captures them, saves, puts the Input Map back to the
# project's defaults, loads, and compares the Input Map with what it should hold.
const SAVED := "user://check_saved.cfg"
const EDITED := "user://check_edited.cfg"
var service: RefCounted
var passed := 0
var total := 0
func _initialize() -> void:
for path in [SAVED, EDITED]:
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
service = load("res://rebinder.gd").new()
var answer: String = service.rebind("jump", key(KEY_K))
check(answer == "" and keys("jump") == [KEY_K] and buttons("jump") == [JOY_BUTTON_A],
"jump takes K in place of Space and keeps button 0: %s" % show("jump"))
answer = service.rebind("move_left", key(KEY_K))
check(answer.contains("jump") and keys("move_left") == [KEY_A],
"K is refused for move_left, which keeps A: \"%s\"" % answer)
answer = service.rebind("jump", button(JOY_BUTTON_B))
check(answer == "" and buttons("jump") == [JOY_BUTTON_B] and keys("jump") == [KEY_K],
"jump takes button 1 in place of button 0: %s" % show("jump"))
answer = service.rebind("move_right", button(JOY_BUTTON_B))
check(answer.contains("jump") and buttons("move_right").is_empty(),
"button 1 is refused for move_right: \"%s\"" % answer)
var stick := InputEventJoypadMotion.new()
stick.axis = JOY_AXIS_RIGHT_X
stick.axis_value = 1.0
answer = service.rebind("jump", stick)
check(answer != "" and buttons("jump") == [JOY_BUTTON_B], "a stick movement is refused: \"%s\"" % answer)
# Space goes to move_up, then W, now free, to jump: the two have swapped keys.
service.rebind("move_up", key(KEY_SPACE))
service.rebind("jump", key(KEY_W))
check(keys("jump") == [KEY_W] and keys("move_up") == [KEY_SPACE], "jump and move_up swap W and Space in two steps")
var error: Error = service.save_bindings(SAVED)
var text := FileAccess.get_file_as_string(SAVED)
check(error == OK and text.contains("87") and not text.contains("Object("),
"save_bindings() writes plain numbers: %s" % ", ".join(text.split("\n", false)))
InputMap.load_from_project_settings()
error = service.load_bindings(SAVED)
check(error == OK and keys("jump") == [KEY_W] and keys("move_up") == [KEY_SPACE] and buttons("jump") == [JOY_BUTTON_B],
"after a reset to the defaults, load_bindings() brings the swap back: %s; %s" % [show("jump"), show("move_up")])
# A file the player edited by hand: a key written as text, an action the game does not have, one good line.
var edited := FileAccess.open(EDITED, FileAccess.WRITE)
edited.store_string("[keys]\n\njump=\"K\"\nfly=70\nmove_down=75\n")
edited.close()
InputMap.load_from_project_settings()
error = service.load_bindings(EDITED)
check(error == OK and keys("jump") == [KEY_SPACE] and keys("move_down") == [KEY_K] and not InputMap.has_action("fly"),
"an edited file: jump=\"K\" and fly=70 are skipped, move_down=75 is taken: %s; %s" % [show("jump"), show("move_down")])
print("%d of %d checks passed" % [passed, total])
quit(0 if passed == total else 1)
# A key press as a rebinding screen captures it, from a keyboard: its place and its label.
func key(code: Key) -> InputEventKey:
var event := InputEventKey.new()
event.physical_keycode = code
event.keycode = code
event.pressed = true
return event
func button(index: JoyButton) -> InputEventJoypadButton:
var event := InputEventJoypadButton.new()
event.button_index = index
event.pressed = true
return event
func keys(action: String) -> Array:
return InputMap.action_get_events(action).filter(func(e: InputEvent) -> bool: return e is InputEventKey).map(
func(e: InputEventKey) -> int: return e.physical_keycode)
func buttons(action: String) -> Array:
return InputMap.action_get_events(action).filter(func(e: InputEvent) -> bool: return e is InputEventJoypadButton).map(
func(e: InputEventJoypadButton) -> int: return e.button_index)
func show(action: String) -> String:
var names := PackedStringArray()
for code: int in keys(action):
names.append(OS.get_keycode_string(code))
for index: int in buttons(action):
names.append("button %d" % index)
return "%s has %s" % [action, ", ".join(names)]
func check(ok: bool, what: String) -> void:
total += 1
passed += int(ok)
print("%s %s" % ["PASS" if ok else "FAIL", what]) The checker rebinds as a settings screen would, saves, resets the Input Map to the project’s defaults and loads, then loads a file edited by hand, with a key written as text and an action the game does not have. On a finished service, and on a tempting one that never looks for conflicts:
from session import files, run
# The checker on a finished rebinding service, then on one that never looks for conflicts.
files("practice/project.godot=right/project.godot", "practice/check_rebinder.gd=right/check_rebinder.gd",
"practice/solution/rebinder.gd=right/rebinder.gd",
"practice/project.godot=tempting/project.godot", "practice/check_rebinder.gd=tempting/check_rebinder.gd",
"practice/tempting/rebinder.gd=tempting/rebinder.gd")
run("""
cd right
godot --headless --no-header --script check_rebinder.gd
cd ../tempting
!godot --headless --no-header --script check_rebinder.gd
""") Output
$ cd right $ godot --headless --no-header --script check_rebinder.gd PASS jump takes K in place of Space and keeps button 0: jump has K, button 0 PASS K is refused for move_left, which keeps A: "K already drives jump" PASS jump takes button 1 in place of button 0: jump has K, button 1 PASS button 1 is refused for move_right: "button 1 already drives jump" PASS a stick movement is refused: "only a key or a gamepad button can be bound" PASS jump and move_up swap W and Space in two steps PASS save_bindings() writes plain numbers: [keys], move_left=65, move_right=68, move_up=32, move_down=83, jump=87, [buttons], jump=1 PASS after a reset to the defaults, load_bindings() brings the swap back: jump has W, button 1; move_up has Space PASS an edited file: jump="K" and fly=70 are skipped, move_down=75 is taken: jump has Space, button 0; move_down has K 9 of 9 checks passed $ cd ../tempting $ godot --headless --no-header --script check_rebinder.gd PASS jump takes K in place of Space and keeps button 0: jump has K, button 0 FAIL K is refused for move_left, which keeps A: "" PASS jump takes button 1 in place of button 0: jump has K, button 1 FAIL button 1 is refused for move_right: "" PASS a stick movement is refused: "only a key or a gamepad button can be bound" PASS jump and move_up swap W and Space in two steps PASS save_bindings() writes plain numbers: [keys], move_left=75, move_right=68, move_up=32, move_down=83, jump=87, [buttons], move_right=1, jump=1 PASS after a reset to the defaults, load_bindings() brings the swap back: jump has W, button 1; move_up has Space PASS an edited file: jump="K" and fly=70 are skipped, move_down=75 is taken: jump has Space, button 0; move_down has K 7 of 9 checks passed [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_session.py
The tempting service passed seven checks: it let K go to move_left while jump had it, and button 1 to move_right,
so one key and one button each drove two actions. With all 9 checks passing, turn to the practice questions: a short
session over this page’s five sections that asks first about the one you know least well.
Practice that adapts to you
Practise this lesson
15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: how do you get the mouse position in world coordinates in a 2D game, and why is event.position not enough?
Call get_global_mouse_position() on a CanvasItem, such as the Node2D that needs the position. In _input() and
_unhandled_input(), a mouse event’s position is in the viewport’s coordinates, pixels of the window, while nodes
live in the world, which a Camera2D scrolls and zooms through the canvas transform. The two agree only while the camera
shows the world from its origin at zoom 1. In a Godot 4.7.2 run, with the camera moved 1,424 px and zoomed to 2, the
mouse at (876, 424) in the window was at (2150, 450) in the world, and a turret that aimed at event.position turned
to 175.9 degrees instead of 21.8. For a node’s own space use get_local_mouse_position(), and to convert a whole event,
make_input_local(event).
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A top-down shooter's turret aims at the mouse correctly at the start of a level, but once the player walks on and the camera zooms in, it points the wrong way. The code is look_at(event.position) in _unhandled_input(). What is wrong, how do you fix it, and what about touch?
-
Mid to senior
A two-player couch game uses one jump action bound to the bottom gamepad button. With two pads connected, both heroes jump when either player presses it. Explain why, and design the input for two to four local players, including a pad that is unplugged during a match.
-
Mid to senior
Design key and gamepad rebinding for a PC game's settings screen: capturing the next press, conflicts, defaults, saving and loading. What do you store on disk, and what can go wrong when a player edits the file or a later version adds an action?
Key takeaways
- A mouse event’s position is in the viewport’s coordinates;
get_global_mouse_position(),get_local_mouse_position()andmake_input_local()give world and node coordinates, which differ from it as soon as a camera moves or zooms. - Each finger sends its own touch and drag events with an index, the first finger also sends emulated mouse events
with device -1 while
emulate_mouse_from_touchis on, and the deadzone of the actions a VirtualJoystick presses decides how much of a drag counts. - Gamepad events carry a device id, and Input Map events with device -1 match every pad, so each local player needs actions or code that check their own device.
- Rebinding replaces events with
InputMap.action_erase_event()andaction_add_event(), refuses keys that other actions use, and saves the player’s choices as plain numbers that the game checks on loading, because Godot does not save the InputMap. - A game pauses on
NOTIFICATION_APPLICATION_FOCUS_OUT, resumes when the player asks, and ignores gamepads while it is unfocused with theignore_joypad_on_unfocused_applicationsetting of Godot 4.7.
Terms in this lesson
- canvas transform
- The transform that turns world positions on a canvas into positions in the viewport, the window's pixels. A Camera2D changes it as it moves and zooms, so get_canvas_transform().affine_inverse() turns a window position into a world position.
- device id
- The number in InputEvent.device that says which device sent an event: 0, 1 and so on for gamepads, in the order Godot found them, and -1 for emulated mouse and touch events. An Input Map event with device -1 matches every device.
- rebinding
- Letting players choose the keys and buttons of the game's actions. The game replaces events with InputMap.action_erase_event() and action_add_event(), and saves the choices in a file of its own, because Godot does not save the InputMap.
- VirtualJoystick
- A Control node, new in Godot 4.7, that draws a stick on a touch screen and presses four actions, ui_left to ui_down unless changed, as its tip is dragged. It can stay fixed, move to the first touch or also follow the finger.
References
- InputEventMouse (class reference) (Godot Engine)
- CanvasItem (class reference): mouse positions and make_input_local() (Godot Engine)
- Camera2D (class reference) (Godot Engine)
- CanvasLayer (class reference) (Godot Engine)
- Mouse and input coordinates (Godot Engine)
- Viewport and canvas transforms (Godot Engine)
- InputEventScreenTouch (class reference) (Godot Engine)
- Input examples: touch events (Godot Engine)
- InputEvent (class reference): device ids (Godot Engine)
- VirtualJoystick (class reference) (Godot Engine)
- Godot 4.7 release notes (Godot Engine)
- Input (class reference) (Godot Engine)
- Controllers, gamepads and joysticks (Godot Engine)
- Controller features: motion sensors and lights (Godot Engine)
- Using InputEvent: InputMap (Godot Engine)
- InputMap (class reference) (Godot Engine)
- Node (class reference): application focus notifications (Godot Engine)
- Pausing games and process mode (Godot Engine)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress