Free Ebook cover Godot 4 for Beginners: Build a Small 2D Game from Scratch

Godot 4 for Beginners: Build a Small 2D Game from Scratch

New course

10 pages

GDScript Basics Applied: Variables, Functions, Input, and State

Capítulo 3

Estimated reading time: 7 minutes

+ Exercise

GDScript that immediately helps your game

In this chapter, we’ll write GDScript in a way that supports fast iteration: values you can tune in the Inspector, references that are safe and readable, and a small state model that keeps behavior predictable. The goal is to make your player controller easy to adjust, debug, and extend.

Script goals for a beginner-friendly player controller

  • Tunable movement via exported variables (speed, jump force, gravity).
  • Clear references to child nodes using @onready and typed variables.
  • Input handling through the Input Map (no hard-coded keys in code).
  • Simple state model: IDLE, MOVING, JUMPING.
  • Safe node access patterns to avoid null errors when nodes are missing or renamed.
  • Clear responsibilities: movement, interactions, animation triggers.
  • Debug prints that you can toggle on/off.

Exported variables: tune gameplay without editing code

Exported variables appear in the Inspector when you select the node that has the script. This lets you iterate quickly: play the game, tweak values, play again.

Step-by-step: add tunable movement variables

  1. Attach a script to your player node (commonly a CharacterBody2D).
  2. Add exported variables for movement and debugging.
extends CharacterBody2D

@export var move_speed: float = 220.0
@export var jump_velocity: float = -420.0
@export var gravity: float = 1200.0

@export var debug_enabled: bool = true

Why these types matter: typing (: float, : bool) helps the editor catch mistakes early and improves autocomplete.

Inspector tuning habit

After you run the game once, stop it, select the Player node, and tweak move_speed or jump_velocity. This is the core loop of “feel” iteration.

@onready references: reliable access to child nodes

Many player scripts need to talk to child nodes (sprite, animation, raycasts, etc.). @onready delays the assignment until the node is in the scene tree, so $Child paths are valid.

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

Example: cache references with types

@onready var anim: AnimationPlayer = $AnimationPlayer
@onready var sprite: Sprite2D = $Sprite2D

If you rename a node, these lines are where you’ll notice quickly. Typed references also help you see what methods are available.

Safe node access patterns (avoid crashes)

During development, nodes may be missing temporarily. Use one of these patterns when you want the game to keep running even if a node isn’t there yet.

PatternWhen to useExample
get_node_or_null()Optional nodes (debug helpers, temporary nodes)var a := get_node_or_null("AnimationPlayer")
is_instance_valid()Nodes that might be freed at runtimeif is_instance_valid(target): ...
Assert earlyRequired nodes (fail fast in development)assert(anim != null)

Example using get_node_or_null:

@onready var anim: AnimationPlayer = get_node_or_null("AnimationPlayer")

func _ready() -> void:
	if anim == null and debug_enabled:
		print("[Player] AnimationPlayer not found; animations will be skipped.")

Input Map: actions instead of hard-coded keys

Godot’s Input Map lets you name actions like move_left and bind multiple keys or controller buttons to them. Your code stays the same even if you change bindings later.

Step-by-step: create actions

  1. Open Project SettingsInput Map.
  2. Add these actions: move_left, move_right, jump, interact.
  3. Bind keys (example): A/Left for move_left, D/Right for move_right, Space for jump, E for interact.

Reading input in code

Use Input.get_axis() for left/right movement and Input.is_action_just_pressed() for one-time events like jumping.

func get_move_input() -> float:
	return Input.get_axis("move_left", "move_right")

A simple state model: idle, moving, jumping

State is a small piece of data that describes what the player is currently doing. Even a basic model prevents messy “if” chains and makes animation triggers consistent.

Define states with an enum

enum PlayerState { IDLE, MOVING, JUMPING }
var state: PlayerState = PlayerState.IDLE

We’ll update state based on input and whether the character is on the floor.

Putting it together: a structured CharacterBody2D controller

This script separates responsibilities into small functions: input gathering, movement physics, state updates, animation triggers, and interactions. That structure makes it easier to debug and extend.

Step-by-step: implement the controller

  1. Attach this script to your Player (CharacterBody2D).
  2. Ensure the node paths match your scene (or switch to get_node_or_null).
  3. Run the game and tune exported values in the Inspector.
extends CharacterBody2D

enum PlayerState { IDLE, MOVING, JUMPING }

@export var move_speed: float = 220.0
@export var jump_velocity: float = -420.0
@export var gravity: float = 1200.0
@export var debug_enabled: bool = true

@onready var anim: AnimationPlayer = get_node_or_null("AnimationPlayer")
@onready var sprite: Sprite2D = get_node_or_null("Sprite2D")

var state: PlayerState = PlayerState.IDLE
var move_input: float = 0.0

func _physics_process(delta: float) -> void:
	read_input()
	apply_gravity(delta)
	handle_jump()
	handle_horizontal_movement()
	move_and_slide()
	update_state()
	update_animation()
	handle_interactions()

func read_input() -> void:
	move_input = Input.get_axis("move_left", "move_right")

func apply_gravity(delta: float) -> void:
	if not is_on_floor():
		velocity.y += gravity * delta

func handle_jump() -> void:
	if Input.is_action_just_pressed("jump") and is_on_floor():
		velocity.y = jump_velocity
		if debug_enabled:
			print("[Player] Jump")

func handle_horizontal_movement() -> void:
	velocity.x = move_input * move_speed
	if sprite != null and move_input != 0.0:
		sprite.flip_h = move_input < 0.0

func update_state() -> void:
	var previous := state
	if not is_on_floor():
		state = PlayerState.JUMPING
	elif abs(velocity.x) > 0.1:
		state = PlayerState.MOVING
	else:
		state = PlayerState.IDLE
	if debug_enabled and state != previous:
		print("[Player] State:", previous, "->", state)

func update_animation() -> void:
	if anim == null:
		return
	match state:
		PlayerState.IDLE:
			if anim.current_animation != "idle":
				anim.play("idle")
		PlayerState.MOVING:
			if anim.current_animation != "run":
				anim.play("run")
		PlayerState.JUMPING:
			if anim.current_animation != "jump":
				anim.play("jump")

func handle_interactions() -> void:
	if Input.is_action_just_pressed("interact"):
		if debug_enabled:
			print("[Player] Interact pressed")
		# Hook: call an interaction system, check an Area2D, etc.

Why this structure works

  • read_input() isolates input gathering. If you later add gamepad aim, you change it here.
  • apply_gravity() keeps physics readable and avoids mixing gravity with other logic.
  • update_state() is the single source of truth for what the player is doing.
  • update_animation() reacts to state; it doesn’t decide state.
  • handle_interactions() is a placeholder for talking to doors, pickups, NPCs, etc.

Debug prints that support iteration (without spamming)

Printing every frame is noisy. Instead, print only when something changes (like state transitions) or when a discrete action happens (jump, interact). The script above prints on jump and on state change.

Optional: a tiny helper for consistent debug output

func dbg(msg: String) -> void:
	if debug_enabled:
		print("[Player] ", msg)

You can then replace prints with dbg("Jump") and dbg("Interact pressed").

Common pitfalls and quick fixes

My player doesn’t move

  • Check the Input Map action names match exactly (move_left, move_right).
  • Verify the script is attached to the correct node (the one that should move).
  • Ensure you’re using _physics_process for physics movement.

Animations don’t play

  • Confirm the node path: AnimationPlayer exists as a child of the player.
  • Confirm the animation names exist: idle, run, jump.
  • If you used get_node_or_null, watch the debug message warning you it wasn’t found.

Jump feels weak/strong

  • Tune jump_velocity (more negative = higher jump).
  • Tune gravity to control fall speed and overall “weight.”
  • Change one value at a time, test, then adjust again.

Now answer the exercise about the content:

Why should a player controller read movement and jump using Input Map actions (for example, Input.get_axis("move_left", "move_right") and Input.is_action_just_pressed("jump")) instead of checking specific keys directly in code?

You are right! Congratulations, now go to the next page

You missed! Try again.

Using Input Map actions avoids hard-coded keys. You can rebind keys/buttons in Project Settings while the code continues to read the same action names, making iteration and changes easier.

Next chapter

D Movement and Physics with CharacterBody2D and Collision Shapes

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.