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
@onreadyand 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
- Attach a script to your player node (commonly a
CharacterBody2D). - 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 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.
| Pattern | When to use | Example |
|---|---|---|
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 runtime | if is_instance_valid(target): ... |
| Assert early | Required 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
- Open Project Settings → Input Map.
- Add these actions:
move_left,move_right,jump,interact. - Bind keys (example):
A/Leftformove_left,D/Rightformove_right,Spaceforjump,Eforinteract.
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
- Attach this script to your Player (
CharacterBody2D). - Ensure the node paths match your scene (or switch to
get_node_or_null). - 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_processfor physics movement.
Animations don’t play
- Confirm the node path:
AnimationPlayerexists 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
gravityto control fall speed and overall “weight.” - Change one value at a time, test, then adjust again.