2D platformers
The component actor, an intent API, physics-expression AnimationTree transitions, codeless moving platforms.
How to assemble a 2D platformer character, its animation, and its level the Godot way:
composition over inheritance, physics as the single source of truth, and almost no glue code.
Distilled from a studied production demo and verified live. Jump feel (coyote
time, buffering, midair jump) lives in game-patterns.md; this file is the architecture.
The actor is a composition, not a class hierarchy#
Don’t subclass CharacterBody2D into a god-object player. The actor root is a plain Node2D
whose children each own one job; the physics body is just one component among peers:
Actor2D (Node2D — tiny script, wiring only)├── KeyboardController (Node — input → intent calls on the body)├── JoyController (Node — same intents from a different device)├── MovingCharacter2D (CharacterBody2D — physics, intent API, predicates)│ ├── CollisionShape2D│ ├── GraphicsRemoteTransform2D → ../Graphics│ └── CameraRemoteTransform2D → the level's camera (rotation/scale updates OFF)├── Graphics (Node2D — sprites; follows the body, never leads it)├── AnimationPlayer + AnimationTree (reads the body's predicates)└── InteractionArea2D (Area2D beacon; follows via its own RemoteTransform2D)Why: controllers swap without touching physics (keyboard/gamepad/AI/replay are siblings, not
subclasses); graphics can lag, squash, or flip without moving colliders; the body scene is a
reusable system you instance into any actor. Physics leads, everything else follows — the
one-way RemoteTransform2D links make that structural, not a convention someone must remember.
The body: intent API + query predicates#
The body script exposes intents (what a controller may ask) and predicates (what observers may read). Controllers never write velocity; animation never reads input:
class_name MovingCharacter2Dextends CharacterBody2Dsignal look_direction_changed(new_vector: Vector2)@export var speed := 500.0@export var gravity := 2000.0@export var jump_strength := 800.0var look_direction := 0: set(value): look_direction = value if look_direction != 0: look_direction_changed.emit(Vector2(look_direction, 1.0))
func _physics_process(delta: float) -> void: velocity.y += gravity * delta velocity.x = look_direction * speed # snappy; blend via move_toward for weight move_and_slide()
func jump() -> void: if is_on_floor(): velocity.y = -jump_strengthfunc cancel_jump() -> void: # jump cutoff, as an intent if is_jumping(): velocity.y *= 0.4func is_falling() -> bool: return velocity.y > 0.0 and not is_on_floor()func is_jumping() -> bool: return velocity.y < 0.0 and not is_on_floor()func is_running() -> bool: return look_direction != 0Tune the body scene itself: node set --node-path Player --properties '{"floor_constant_speed":true,"floor_snap_length":16.0}' (constant speed on slopes; snap keeps
is_on_floor() true over slope crests and platform seams). Flip facing with a signal, not code:
node connect --node-path Player --signal look_direction_changed --target-path Graphics --method set_scale
— emitting Vector2(-1, 1) mirrors the whole rig.
Physics drives the AnimationTree — no glue code#
The payoff of the predicate API: state-machine transitions advance themselves by evaluating
expressions against the body every frame. No travel() calls, no animation code anywhere.
Build (verified live; states idle/fall over an existing Anims AnimationPlayer):
anim-tree create --node-path . --anim-player Anims --name Treeanim-tree add-state --node-path Tree --state-name idle --animation idleanim-tree add-state --node-path Tree --state-name fall --animation fallanim-tree add-transition --node-path Tree --from-state Start --to-state idle --advance-mode autoanim-tree add-transition --node-path Tree --from-state idle --to-state fall \ --advance-mode auto --advance-expression 'not get_node("Player").is_on_floor()' --xfade-time 0.1anim-tree add-transition --node-path Tree --from-state fall --to-state idle \ --advance-mode auto --advance-expression 'get_node("Player").is_on_floor()' --xfade-time 0.1node set --node-path Tree --properties '{"advance_expression_base_node":"..","active":true}'advance_expression_base_node is where get_node() resolves from — point it at the actor root.
Full graph for a platformer: idle ⇄ run (is_running()), any-ground → jump (is_jumping(),
switch-mode at_end off looping states), jump → fall (is_falling()), fall → idle/run on
is_on_floor(). Verify in-game, numerically:
runtime eval --code 'emit(get_tree().current_scene.get_node("Tree").get("parameters/playback").get_current_node())'.
Moving platforms with zero code#
AnimatableBody2D (not Rigid, not Static) carries riders correctly. Drive it by animating a
PathFollow2D’s progress_ratio and mirroring through RemoteTransform2D:
Path2D (the route, any Curve2D shape)├── PathFollowPlatform2D (rotates=false)│ └── RemoteTransform2D → ../MovingPlatform2D├── MovingPlatform2D (AnimatableBody2D, layer 2)│ ├── CollisionPolygon2D (one_way_collision = true)│ └── Polygon2D (visual)└── AnimationPlayer (keys progress_ratio 0→1; loop ping-pong, or linear for circuits)Build: node add --type Path2D + path2d-style curve via node set --properties '{"curve": ...}' or reuse a saved platform scene; animation create --node-path AP --name idle --length 2 then animation add-track / set_keyframe on PathFollowPlatform2D:progress_ratio
(0.0 at t=0, 1.0 at t=2), --autoplay. Desync duplicates with one embedded line:
speed_scale = randf_range(0.5, 1.0) in a tiny extends AnimationPlayer script. The platform
never knows it’s moving; the rider never knows it’s on a platform.
The camera belongs to the level#
The level owns Camera2D (limits are level knowledge); the actor only feeds it, via a
RemoteTransform2D on the body with update_rotation=false, update_scale=false. Inject the
camera as an exported NodePath on the actor. Use built-in smoothing
(position_smoothing_enabled, limit_smoothed) — never hand-lerp.
Per-region framing: invisible Area2D trip lines (a SegmentShape2D across a doorway) that
rewrite camera.limit_left/right/top/bottom on area_entered, with -1 meaning “leave as is”.
Room-based cameras fall out of level layout, no camera manager.
Collision layers are a contract — write it down#
| layer | who | notes |
|---|---|---|
| 1 | world (tiles, slopes) | static geometry |
| 2 | platforms | one-way, moving |
| 4 | hazards/enemies | |
| 8 | player presence | body and its interaction beacon |
Player body: layer 8, mask 1|2|4. The interaction pair is deliberately asymmetric: the actor
carries a dumb beacon (Area2D, layer 8, monitoring=false), the world carries sensors
(Area2D, layer 0, mask 8, monitorable=false) that own the response. A sensor enables its
input handling only while overlapped (set_process_unhandled_input on enter/exit) — no global
“can I interact?” state. Set with physics set-layers or node set on collision_layer/collision_mask.
Blockout the level as scene tiles#
Registering prefab scenes as tiles gives painted cells real collision, visuals, and behavior — the fastest greybox that still plays. Build (verified live):
tilemap create --parent-path . --name Ground --tile-size 'Vector2(128,128)'tilemap add-scenes-source --node-path Ground --scenes '["res://tiles/floor.tscn","res://tiles/slope.tscn"]'tilemap fill-rect --node-path Ground --x1 0 --y1 5 --x2 24 --y2 6 --source-id 0 --alternative 1Each prefab is a 1-cell scene (visual polygon + StaticBody2D collision) centered on the origin.
For texture tiles instead: tilemap add-atlas-source --node-path Ground --texture <png> --tile-size 'Vector2(256,256)' (tiles auto-created for the whole grid) and paint with
--atlas-x/--atlas-y. Check what a layer holds with tilemap get-info.
Teach in the world, not in menus#
Tutorial prompts are level objects: an Area2D sensor + RichTextLabel that pops when entered.
Two details worth stealing: the label text holds an {input} placeholder replaced at runtime
with a keyboard or gamepad glyph depending on Input.get_connected_joypads() (store both glyph
paths as node metadata via node set-meta); and the dismiss animation is the pop animation
played backwards (play_mode backward on a second state), so one clip serves both. Even this
UI runs on expression-driven transitions (get_overlapping_areas().size() > 0) — the same
pattern as the character, applied to presentation. See in-game-docs.md for the 3D equivalents.
Checklist#
- Actor root is a
Node2D; body, controllers, graphics, interaction are sibling components. - Controllers call intents; animation reads predicates; nobody else touches
velocity. - AnimationTree transitions are
--advance-mode auto+ expressions;advance_expression_base_nodeset. - Graphics/camera/interaction follow the body via
RemoteTransform2D, never the reverse. - Platforms are
AnimatableBody2Don an animatedPathFollow2D; one-way collision on. - Camera lives in the level; limits change via trip-line areas.
- Collision layers documented as a table in the project; beacons don’t monitor, sensors aren’t monitorable.
- Blockout painted as scene tiles;
scene saveafter; verify state live withruntime eval.