If you want to learn how to make a 3D platformer in Godot, the shortest honest path is this: one player scene built on CharacterBody3D, one movement script that feels good to jump with, a camera that follows without fighting you, and a level made of simple blocks you can replace with real art later. This guide walks through each piece with complete GDScript for Godot 4, explains the beginner terms as they come up, and covers the mistakes that make first platformers feel floaty or broken.
Godot is a free, open-source engine released under the MIT License, and as of October 2026 its latest stable release is 4.7.2. Everything below uses standard Godot 4 nodes and APIs, so it works across recent 4.x versions.
What you need before you start
You need the Godot 4 editor and a basic grasp of two ideas Godot is built around:
- Nodes: single building blocks, such as a camera, a light, a mesh or a physics body. Each node does one job.
- Scenes: a saved tree of nodes. Your player is a scene, a coin is a scene, and your level is a scene that contains instances of the others.
Scripts are written in GDScript, Godot’s Python-like language. If you have never touched Godot, the official “Your first 3D game” tutorial covers 3D coordinates, a jumping mechanic, kinematic movement and collision detection, and the docs suggest beginners start with the 2D series first. That advice is sound: 3D adds a camera problem that 2D does not have.
If you would rather study a finished controller before writing your own, the 3D Platformer Starter Kit on GitHub is a free template tested in Godot 4.1 and above, with idle, run, jump and flip animations and a heavily commented player script. Reading it alongside this guide is a good way to see the same ideas solved slightly differently.
How to make a 3D platformer in Godot: the core scene setup
Create a new project, then set up input before any code. Open Project Settings, go to the Input Map tab and add these actions, binding each to a key and, if you like, a gamepad input:
move_left,move_right,move_forward,move_back(WASD or a stick)jump(Space or the bottom face button)cam_left,cam_right(Q and E, or the right stick, for keyboard camera turning)
Naming actions instead of reading raw keys means you can rebind controls later without touching the scripts.
The player scene
Make a new scene with a CharacterBody3D as the root. This is Godot’s node for characters you move with code rather than with physics forces; it gives you velocity, move_and_slide() and is_on_floor() for free. Build this tree:
Player (CharacterBody3D) player.gd
├── CollisionShape3D CapsuleShape3D, height 1.8
├── Model (Node3D) your character mesh goes in here
└── CameraPivot (Node3D) camera_pivot.gd, position y = 1.5
└── SpringArm3D spring_length = 5
└── Camera3D
A collision shape is the invisible volume the physics engine actually tests against; a capsule is the standard choice because it slides over small bumps instead of catching on them. The Model node is separate so you can rotate the visible character to face its movement without rotating the body or the camera. Add the player to a group called player (Node tab, Groups) so pickups can recognise it.
The level scene
Make a second scene with a Node3D root, add a DirectionalLight3D, a WorldEnvironment for sky and ambient light, and an instance of your player. For platforms, use CSGBox3D nodes with Use Collision turned on. CSG (constructive solid geometry) boxes are quick to resize in the editor, which makes them ideal for blocking out a level. Swap them for proper StaticBody3D meshes once the layout is fun.

Writing the player movement script
This is where most of the “feel” of a platformer lives. The script below does five things: gravity, a heavier fall for a snappier arc, coyote time, jump buffering and variable jump height. Two of those need a definition:
- Coyote time: a short window after walking off a ledge where a jump still counts. Players perceive it as fairness, not as a cheat.
- Jump buffering: remembering a jump press made just before landing, so the jump fires the moment the player touches the ground.
Attach this as player.gd on the Player node:
extends CharacterBody3D
@export var speed := 7.0
@export var acceleration := 40.0
@export var jump_velocity := 9.0
@export var fall_multiplier := 1.8
@export var coyote_time := 0.12
@export var jump_buffer_time := 0.12
@onready var camera_pivot: Node3D = $CameraPivot
@onready var model: Node3D = $Model
var gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity")
var coyote_timer := 0.0
var jump_buffer_timer := 0.0
var spawn_point := Vector3.ZERO
func _ready() -> void:
spawn_point = global_position
func _physics_process(delta: float) -> void:
# Gravity, stronger while falling for a less floaty arc
if is_on_floor():
coyote_timer = coyote_time
else:
var mult: float = fall_multiplier if velocity.y < 0.0 else 1.0
velocity.y -= gravity * mult * delta
coyote_timer -= delta
# Jump buffering
if Input.is_action_just_pressed("jump"):
jump_buffer_timer = jump_buffer_time
else:
jump_buffer_timer -= delta
if jump_buffer_timer > 0.0 and coyote_timer > 0.0:
velocity.y = jump_velocity
jump_buffer_timer = 0.0
coyote_timer = 0.0
# Variable jump height: release early for a short hop
if Input.is_action_just_released("jump") and velocity.y > 0.0:
velocity.y *= 0.5
# Camera-relative movement
var input_dir := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
var cam_basis := camera_pivot.global_transform.basis
var direction := cam_basis.x * input_dir.x + cam_basis.z * input_dir.y
direction.y = 0.0
direction = direction.normalized()
var target := direction * speed
velocity.x = move_toward(velocity.x, target.x, acceleration * delta)
velocity.z = move_toward(velocity.z, target.z, acceleration * delta)
# Turn the visible model toward the movement direction
if direction.length() > 0.1:
var target_angle := atan2(direction.x, direction.z)
model.rotation.y = lerp_angle(model.rotation.y, target_angle, 12.0 * delta)
move_and_slide()
if global_position.y < -20.0:
respawn()
func respawn() -> void:
global_position = spawn_point
velocity = Vector3.ZERO
A few notes on why it is written this way. Movement is built from the camera pivot’s basis (its local axes), so “forward” always means “away from the camera”, which is what players expect in a third-person platformer. move_toward gives acceleration and deceleration instead of instant starts and stops; raise acceleration for tighter control, lower it for a slippery feel. If your imported character faces backwards while running, add PI to target_angle, because models from different tools disagree about which way is forward.
The exported variables show up in the Inspector, so you can tune jump height and speed while the game runs. That tuning loop, not the code itself, is most of the work when you figure out how to make a 3D platformer in Godot feel good.
Building a third-person camera that works
The camera is the part beginners underestimate. A camera stuck rigidly behind the player clips through walls, and a free mouse camera with no limits lets players look straight up through the floor. The fix is a spring arm: a SpringArm3D casts a shape from the pivot toward the camera and pulls the camera in when something is in the way.
The scene tree already splits rotation across two nodes on purpose. The pivot handles yaw (turning left and right), and the spring arm handles pitch (looking up and down). Movement reads only the pivot’s basis, so tilting the camera never makes the player walk into the ground. Attach this as camera_pivot.gd:
extends Node3D
@export var mouse_sensitivity := 0.003
@export var key_turn_speed := 2.5
@export var min_pitch_deg := -60.0
@export var max_pitch_deg := 20.0
@onready var spring_arm: SpringArm3D = $SpringArm3D
func _ready() -> void:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
# Stop the spring arm colliding with the player's own capsule
spring_arm.add_excluded_object(get_parent().get_rid())
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseMotion:
rotation.y -= event.relative.x * mouse_sensitivity
spring_arm.rotation.x -= event.relative.y * mouse_sensitivity
spring_arm.rotation.x = clampf(spring_arm.rotation.x,
deg_to_rad(min_pitch_deg), deg_to_rad(max_pitch_deg))
if event.is_action_pressed("ui_cancel"):
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
func _process(delta: float) -> void:
# Keyboard or right-stick camera turning
var turn := Input.get_axis("cam_right", "cam_left")
rotation.y += turn * key_turn_speed * delta
Both mouse and keyboard turning feed the same rotation.y, so you can support either control scheme, or both, without changing the movement code. Start the pitch limits narrow; a camera that cannot look too far down is rarely a complaint, while one that shows the underside of the level always is.





