Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with godot 4.7.2-stable and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 3 interview questions.

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:

aim_at_mouse.gd: two turrets and a moving camera GD · aim_at_mouse.gd
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])
The mouse at one window point, before and after the camera moves, Godot 4.7.2 Python · aim_session.py
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:

Interactive · step by step One mouse position in three spaces Step through the second half of the run, after the camera has moved. Sub-labels show the run's numbers.
One mouse position in three spaces, step 6 of 6. Showing Window point, Camera2D (at (2000, 400), zoom 2), World point ((2150, 450)), RightTurret (at (1900, 350)), Turret's own space ((250, 100), 21.8 degrees), WrongTurret (turned to 175.9 degrees). In focus: WrongTurret.canvas transformundo the canvasundo the turrettaken as a world pointWindow pointevent.position (876, 424)Camera2Dat (2000, 400), zoom 2World point(2150, 450)RightTurretat (1900, 350)Turret's own space(250, 100), 21.8 degreesWrongTurretturned to 175.9 degrees(876, 424)

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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()).

Trigonometry Calculator Check the turret's angle yourself: the arctangent of 100 / 250 is 21.8 degrees.

Checkpoint

Check: Window, world and local positions

  1. Question 1 of 2 The window is 1152 px wide. A Camera2D at x = 1000, with zoom 1 and the default anchor at its centre, shows the world. The mouse is at x = 100 in the window. What is its x in the world?

    Type a number.

    A hint

    The camera's x is the world point at the middle of the window, 576 px from its left edge.

    Show the answer: Question 1 of 2

    Answer: 524 px

    The window's middle, x = 576, shows world x = 1000, so window x = 100 is 476 px further left: 1000 - 476 = 524. In the run's terms, the canvas origin is -1000 + 576 = -424, and 100 + 424 = 524.

  2. Question 2 of 2 A Sprite2D in a level with a moving, zooming Camera2D must sit exactly under the mouse. Which line in its _process() does it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: global_position = get_global_mouse_position()

    World positions come from get_global_mouse_position(); by hand, the inverse of the canvas transform turns a window point into a world point.

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:

touch_and_stick.gd: two fingers, then a VirtualJoystick GD · touch_and_stick.gd
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)
Two fingers and a joystick, Godot 4.7.2 Python · touch_session.py
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:

Interactive · message by message A finger on the VirtualJoystick Step through the drag. Arrows are the touches the joystick receives and the actions it presses.
A finger on the VirtualJoystick, step 6 of 6. Messages so far: Finger 0 to VirtualJoystick: touch down; VirtualJoystick to Input: right at 0.5; Finger 0 to VirtualJoystick: drag to 50 px; VirtualJoystick to Input: release.Finger 0a touch screenVirtualJoystick100 px acrossInputnothing pressedtouch downright at 0.5drag to 50 pxrelease

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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).
  4. 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.
  5. 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).
  6. 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

  1. Question 1 of 2 With the default project settings, a game reacts only to InputEventMouseButton. A player taps with one finger, and then with a second finger while the first is still down. What does the game see?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A mouse press for the first finger, and nothing for the second

    Read InputEventScreenTouch and InputEventScreenDrag for more than one finger; the emulated mouse covers the first finger only.

  2. Question 2 of 2 A VirtualJoystick of the default size, 100 px, keeps its default ui_ actions, and the hero moves by get_vector() over them. A player drags the tip 20 px to the right. What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Nothing, because the strength of 0.4 is inside the 0.5 deadzone of the ui_ actions

    20 px of a 50 px radius is a strength of 0.4. get_vector() over the ui_ actions uses their average deadzone, 0.5, so it returns (0, 0); pointed at the game's move actions, the joystick would move the hero.

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:

pads.gd: two pads, three actions and a vibration GD · pads.gd
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)
Button A on pad 1, then on pad 0, Godot 4.7.2 Python · pads_session.py
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

  1. Question 1 of 2 Two players share one computer, each with a pad. Both heroes jump on jump, an action made in the Input Map with joypad button 0. Player 2 presses A. What happens?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Both heroes jump, because the action's event, device -1, matches every pad

    Give each player actions bound to their device, as p1_jump and p2_jump were, or read the event's device in _unhandled_input().

  2. Question 2 of 2 How should a two-player game decide which pad belongs to player 1?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Let each player press a button on their pad, and keep the device of that press for the player

    Claiming a pad with a press is reliable; listen to joy_connection_changed as well, for pads that come and go.

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:

rebind_keys.gd: a rebinding screen in miniature GD · rebind_keys.gd
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)])
jump takes K, is saved, reset and loaded, Godot 4.7.2 Python · rebind_session.py
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:

Interactive · stage by stage Giving jump a new key Step through the rebinding of the run. The sub-label of each stage says what happened there.
Giving jump a new key, step 6 of 6. Showing The player picks jump, _input() takes a press (K, physical keycode 75), Is it taken already?, Swap the event (Space out, K in), Save plain numbers, Load at the next start (jump has K again). In focus: Load at the next start.refusedThe player picks jumpthe screen waits for a key_input() takes a pressK, physical keycode 75Is it taken already?if so, refuse and say whySwap the eventSpace out, K inSave plain numbersjump=75 under [keys]Load at the next startjump has K again

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. jump is chosen. The player picks jump on the rebinding screen, and the Rebinder waits for the next key press.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. Question 1 of 2 jump holds the key Space and joypad button 0. The player gives jump the key K. Which calls keep the gamepad button?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: InputMap.action_erase_event("jump", the Space event), then InputMap.action_add_event("jump", a K event)

    Erase the one event that is replaced, then add the new one.

  2. Question 2 of 2 What should the game write to its settings file for jump's new key, K?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Its physical keycode as a number, 75, under the action's name

    Save the smallest plain data that rebuilds the binding, and check it when it is read back.

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:

focus_pause.gd: pause on focus loss, resume on Escape GD · focus_pause.gd
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
Focus lost and back, Godot 4.7.2 Python · focus_session.py
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:

Interactive · message by message Losing the focus, and coming back Step through the run. Arrows from the player are keys and focus changes; the sub-labels show the state.
Losing the focus, and coming back, step 6 of 6. Messages so far: Player to Hero: Space; Player to Game: focus out; Player to Hero: Space; Player to Game: focus in; Player to Game: Escape; Player to Hero: Space.Playerkeys and the windowGamerunning againHeropauses with the gameSpacefocus outSpacefocus inEscapeSpace

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Playing. While the game runs, Space reaches the Hero's _unhandled_input(), and the Hero jumps.
  2. Focus lost. The player switches to another program. NOTIFICATION_APPLICATION_FOCUS_OUT reaches every node, and Game sets get_tree().paused to true.
  3. Away. Space while the player is away does nothing. The Hero is paused, so its _unhandled_input() is not called.
  4. Back. The player comes back. Game hears NOTIFICATION_APPLICATION_FOCUS_IN and leaves the game paused, so the player decides when it goes on.
  5. 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.
  6. 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

  1. Question 1 of 2 The game sets get_tree().paused = true when the window loses the focus. A Hero with the default process mode reads jump in _unhandled_input(). The player presses Space while away. What happens?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Nothing, because a paused node's input callbacks are not called

    Nodes inherit the tree's pause by default; a node that must keep listening, such as a pause menu, sets its process mode to Always or When Paused.

  2. Question 2 of 2 A player leaves the game window and plays with a gamepad in another program. With the default settings, what does the game receive?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The pad's input, because controller input reaches unfocused windows by default

    Set input_devices/joypads/ignore_joypad_on_unfocused_application, or Input.ignore_joypad_on_unfocused_application, to true in Godot 4.7, and pause the game on focus loss.

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.

  1. In a Godot 4.7 project with the five actions of the lesson before, move_left, move_right, move_up and move_down on the places of A, D, W and S and the left stick, and jump on Space and joypad button 0, save rebinder.gd and check_rebinder.gd from below.
  2. Finish the three functions. A refusal returns a sentence that names the action that already has the key or button.
  3. In a terminal in the project’s folder, run godot --headless --no-header --script check_rebinder.gd.
rebinder.gd: the starter GD · practice/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
check_rebinder.gd: the checker of this task GD · practice/check_rebinder.gd
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:

The checker on a finished service and on a tempting one, Godot 4.7.2 Python · check_session.py
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.

  1. Question 1 of 15 In _unhandled_input(), which coordinates does the position of an InputEventMouseMotion use?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: The viewport's coordinates, pixels of the window

    In a Control's _gui_input() the position is local to the Control instead.

  2. Question 2 of 15 A turret stands at (1900, 350) in the world, not rotated or scaled, and the mouse is at (2150, 450) in the world. What does the turret's get_local_mouse_position() return?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: (250, 100)

    With no rotation or scale, the local point is the world point minus the node's position.

  3. Question 3 of 15 A crosshair sits on a CanvasLayer of the HUD, and the level has a zooming camera. Which position puts the crosshair under the mouse?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: get_global_mouse_position() called on the crosshair, which is relative to its own CanvasLayer

    Ask the node that is drawn: each CanvasLayer has its own transform, and a CanvasLayer that does not follow the viewport stays fixed on the screen (CanvasLayer reference).

  4. Question 4 of 15 Which events does a finger on a touch screen send?

    Choose one answer.

    Show the answer: Question 4 of 15

    Answer: InputEventScreenTouch when it goes down or up and InputEventScreenDrag while it moves, each with the finger's index

    The InputEventScreenTouch reference: "One index = one finger."

  5. Question 5 of 15 A game reads both mouse clicks and touches, and on phones every tap fires its action twice. Why, and what fixes it?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: The first finger also sends an emulated mouse click with device -1; skip mouse events whose device is InputEvent.DEVICE_ID_EMULATION

    Either skip events with device -1, or turn input_devices/pointing/emulate_mouse_from_touch off when the game handles touches itself.

  6. Question 6 of 15 On a phone, players rest their thumbs in different places of the joystick area, and in long fights their thumbs drift beyond the stick's edge. Which VirtualJoystick mode fits?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: Following

    The VirtualJoystick reference: Fixed does not move, Dynamic moves to the initial touch, and Following also follows the touch outside its range.

  7. Question 7 of 15 What does InputEvent.device tell you about a joypad event?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: Which pad sent it, 0 for the first one Godot found, 1 for the second and so on

    An action event with device -1 matches every device; one with a number matches that pad only.

  8. Question 8 of 15 What does Input.start_joy_vibration(1, 0.2, 0.6, 0.5) ask for?

    Choose one answer.

    Show the answer: Question 8 of 15

    Answer: Pad 1 vibrates with its weak motor at 0.2 and its strong motor at 0.6, for half a second

    The run asked pad 0 for (0.4, 0.8) for 0.25 s, and get_joy_vibration_strength() returned (0.4, 0.8).

  9. Question 9 of 15 In a two-player game, player 2's pad is unplugged in the middle of a match. What should the game do?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: Hear joy_connection_changed with connected false, pause player 2's side, and let player 2 claim a pad again with a press

    Treat pads as coming and going, and let players claim them.

  10. Question 10 of 15 Does Godot save the changes a game makes with InputMap.action_add_event()?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: No; the game must save them in a file of its own and apply them when it starts

    InputMap.load_from_project_settings() brings the project's defaults back, which is a ready-made Reset to defaults.

  11. Question 11 of 15 Why does the run's Rebinder read the next key in _input() and then mark the event as handled?

    Choose one answer.

    Show the answer: Question 11 of 15

    Answer: So that it gets the press before the UI and the game, and the press does nothing else

    A capture is one of the rare jobs for _input(): it must see the key first, and it keeps it.

  12. Question 12 of 15 A player gave jump the key W and move_up the key Space. On the next start, the defaults are back, jump on Space and move_up on W, and the loader applies move_up=32 first. What must load_bindings() do so that the swap survives?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: Take off every key the file replaces before it adds any, so that the conflict check sees only the new keys

    Loading is a set of changes made at once: remove what is replaced, then add, then check what is left.

  13. Question 13 of 15 Which notification tells the nodes of a game that the player switched to another program?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: NOTIFICATION_APPLICATION_FOCUS_OUT

    NOTIFICATION_APPLICATION_FOCUS_IN is sent when the focus comes back.

  14. Question 14 of 15 The pause menu must react to Escape while get_tree().paused is true. What does its root node need?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: A process mode of When Paused or Always

    The Pausing games page: nodes inherit the tree's pause unless their process mode is When Paused or Always.

  15. Question 15 of 15 Players of a racing game drive with a pad and often look at a map in a browser window, where the pad scrolls the map. What should the game do?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: Pause on focus loss and set ignore_joypad_on_unfocused_application to true

    Godot 4.7's setting ignores joypad input and stops vibration while the game is unfocused; pausing keeps the race where the player left it.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A mouse event’s position is in the viewport’s coordinates; get_global_mouse_position(), get_local_mouse_position() and make_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_touch is 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() and action_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 the ignore_joypad_on_unfocused_application setting 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.