esc
navigate openSearch by Pagefind

Game patterns

Buildable patterns mapped to command sequences: movement, game feel vs juice, components, state machines, HUD.

How to build games the Godot way, mapped to the CLI. Read gdscript-style.md for language idioms. Verify exact APIs against the live engine (engine class-info --class CharacterBody2D, engine search --query <thing>). The patterns below are stable, but signatures evolve.

Principles#

  • Compose, don’t centralize. One small scene per entity/UI piece, instanced into levels (scene.instance). Build capability from child nodes, behavior from focused scripts. (See SKILL.md “Build with composition, not monoliths”.)
  • Components. Recurring behavior → its own small scene + script (HealthComponent, HurtboxComponent), instanced wherever needed. Components expose signals; the owner wires them.
  • Data-driven. Stats/items/config as Resource types (.tres), not hard-coded. See Content as data below for when a JSON sidecar registry serves better.
  • Decouple with signals. Emitter announces; listeners react. No per-frame polling of other nodes, no reaching across the tree with get_node chains.
  • Separate data (Resources) / logic (scripts on the owning node) / presentation (Sprite, AnimationPlayer, UI). Don’t put all three in one script.
  1. project info; set viewport/window via project set-setting.
  2. Input map first: input_map set-action for every action (move_left, jump, …).
  3. Player scene in its own .tscn, with a movement script and collision.
  4. One level scene. Instance the player, then build the world (TileMapLayer / 3D meshes). An instanced child’s own subtree is sealed, so tuning a node inside it from the level needs editable children first: node set-editable-instance --node-path Player --editable true, and node set then reaches Player/Sprite2D.
  5. Core loop: enemies, pickups, win/lose, all via component scenes.
  6. UI/HUD, Control nodes bound to gameplay by signals.
  7. Playtest: scene playinput.*runtime.get/screenshot → fix → repeat.
  8. Polish. Animation, particles, audio, juice (tweens).

Top-down movement (CharacterBody2D)#

extends CharacterBody2D
@export var speed: float = 300.0
func _physics_process(_delta: float) -> void:
var dir := Input.get_vector("move_left", "move_right", "move_up", "move_down")
velocity = dir * speed
move_and_slide()

Build it:

scene create --path res://entities/player.tscn --root-type CharacterBody2D --root-name Player
scene open --path res://entities/player.tscn
node add --type Sprite2D --name Sprite --parent-path .
node add --type CollisionShape2D --name Col --parent-path .
node add-resource --node-path Col --property shape --resource-type CircleShape2D
script create --path res://entities/player.gd --extends CharacterBody2D
script attach --node-path . --script-path res://entities/player.gd
scene save

(Define move_left/right/up/down via input_map set-action first; Input.get_vector returns a normalized direction.)

Platformer movement#

extends CharacterBody2D
@export var speed := 250.0
@export var jump_velocity := -400.0
var gravity: float = ProjectSettings.get_setting("physics/2d/default_gravity")
func _physics_process(delta: float) -> void:
if not is_on_floor():
velocity.y += gravity * delta
if Input.is_action_just_pressed("jump") and is_on_floor():
velocity.y = jump_velocity
velocity.x = Input.get_axis("move_left", "move_right") * speed
move_and_slide()

Read platformer-2d.md for how the platformer actor and level are assembled: component actor, physics-driven AnimationTree, moving platforms, cameras, collision layers, and scene-tile blockouts. This file owns the movement code and its feel.

Game feel vs juice: two different layers#

They live in different code. Conflating them is the common mistake.

  • Game feel is the quality of control in the input→avatar loop: responsiveness, weight, momentum, camera. It lives in the movement/simulation code. Strip every visual effect and it’s still there (or still missing); you tune it with physics constants and input handling.
  • Juice is the feedback layer, the avatar→audiovisual reaction fired on events to make an action satisfying and legible. It lives in the presentation layer (tweens, particles, signals→effects). Remove all of it and the mechanics are unchanged.

One feels good in the hands, the other reads well to the eyes/ears. Build game feel first (core mechanics, so it belongs in the designer greybox); add juice second (cheap, works on grey cubes, right for a stakeholder prototype). See level-design.md “Presentation stages”.

Tier it Big → Medium → Small (risk order): Big feel = movement, jump, camera, combat. Get these right first; if they feel bad nothing else matters. Medium feel = landing effects, hit reactions, camera shake, recoil. Small feel = dust, sparks, shell casings, UI flourishes. Big feel is mostly game feel (control); Medium/Small are mostly juice. (See level-design.md “Big → Medium → Small”.)

Game feel makes the control feel good (movement code)#

Augment the platformer above; all verified against the live engine.

Weight via acceleration (instant velocity feels robotic):

const ACCEL := 1800.0
const FRICTION := 2200.0
func _physics_process(delta: float) -> void:
var dir := Input.get_axis("move_left", "move_right")
var rate := ACCEL if dir != 0.0 else FRICTION
velocity.x = move_toward(velocity.x, dir * speed, rate * delta)
# ... gravity + move_and_slide()

Coyote time + jump buffer + variable height (forgiving, responsive jumps):

@export var coyote_time := 0.1
@export var jump_buffer := 0.1
var _coyote := 0.0
var _buffered := 0.0
func _physics_process(delta: float) -> void:
_coyote = coyote_time if is_on_floor() else _coyote - delta
_buffered -= delta
if Input.is_action_just_pressed("jump"):
_buffered = jump_buffer
if _buffered > 0.0 and _coyote > 0.0: # buffered press + recently grounded
velocity.y = jump_velocity
_buffered = 0.0
_coyote = 0.0
if Input.is_action_just_released("jump") and velocity.y < 0.0:
velocity.y *= 0.4 # variable height: cut the rise short

Midair (double) jump (aerial correction that consumes a charge, refilled on landing):

@export var max_air_jumps := 1
@export var air_jump_velocity := -340.0 # weaker than the ground jump keeps commitment
var _air_jumps := 0
# inside _physics_process, after the coyote/buffer ground jump:
if is_on_floor():
_air_jumps = max_air_jumps
elif Input.is_action_just_pressed("jump") and _air_jumps > 0:
velocity.y = air_jump_velocity
_air_jumps -= 1

Layering: cutoff shapes the arc (micro), the midair jump rescues it (macro), coyote time makes the first jump reliable so the second stays a deliberate tool, and the buffer keeps fast land→jump→air-jump chains consistent. To preserve high-stakes jumps, constrain it: lower air_jump_velocity, one charge, or gate it as a progression unlock (flip max_air_jumps from a pickup). Level design: midair jump enables commit-vs-correct patterns such as gaps the first jump almost clears, layered vertical routes, and rescue ledges under hazards.

Air control (steering after takeoff, since a second jump is only fair if you can aim it):

@export var air_accel := 1100.0 # < ground ACCEL: drifty but steerable
var rate := (ACCEL if is_on_floor() else air_accel) if dir != 0.0 else FRICTION

Asymmetric gravity (a snappier arc: float up, fall fast):

velocity.y += (fall_gravity if velocity.y > 0.0 else rise_gravity) * delta

Camera that follows with weight, using built-in smoothing (don’t hand-roll a lerp):

node add --type Camera2D --name Cam --parent-path .
node set --node-path Cam --properties '{"position_smoothing_enabled":true,"position_smoothing_speed":8.0}'

(position_smoothing_speed confirmed on Camera2D; for lookahead, offset the camera toward the velocity.x direction.) Also game feel, not juice: read input every frame, snappy turnarounds, sub-pixel movement, low latency. The 3D third-person equivalent is SpringArm3D: parent the Camera3D to it and set length. The arm raycasts back from the pivot and shortens when geometry intrudes, so the camera never clips walls (margin pads the hit; add_excluded_object ignores the player’s own collider).

Juice makes the action satisfying (presentation, fired on events)#

None of these need final art, since they work on cubes and spheres. Fire them from gameplay signals.

Squash & stretch (the workhorse for jump, land, hit, and collect):

func pop(sx: float, sy: float, t := 0.12) -> void:
var tw := create_tween()
tw.tween_property(sprite, "scale", Vector2(sx, sy), t * 0.4)
tw.tween_property(sprite, "scale", Vector2.ONE, t * 0.6).set_trans(Tween.TRANS_BACK)
# jump: pop(0.7, 1.3) land: pop(1.3, 0.7)

Combat-VFX shader grammar (from a shipped commercial deck-builder’s 2D VFX library, the system behind every impact/burst/ring effect, built on GPUParticles2D + canvas_item shaders):

  • Lifetime drives everything. In the shader, INSTANCE_CUSTOM.y / INSTANCE_CUSTOM.w is the particle’s 0→1 age; sample authored 1D curve textures at that value (CurveTexture for erosion threshold, flipbook frame, hue shift) instead of hardcoding math, so artists tune curves rather than code.
  • Grayscale + LUT. Particle textures are grayscale masks; color comes from texture(lut, mask.rr), so one texture serves every palette, recolored per effect.
  • Erosion dissolve: smoothstep(threshold, threshold + softness, noise.r) with the threshold read from the lifetime curve. That is the standard burn-away for smoke/impact sprites.
  • Flipbook UVs in-shader (grid size + frame from lifetime, per-instance offset via INSTANCE_CUSTOM.z so instances desync).
  • Polar UV remap (radius, angle from center, optional twist) turns any linear gradient into rings and radial shockwaves.
  • Screen distortion: offset hint_screen_texture UVs along the direction to the sprite’s center, masked by the particle texture, with intensity riding the particle’s color alpha, so the particle system animates the shader parameter for free. When a shader must read what was drawn below it mid-frame (refraction over a specific region, frosted panels), place a BackBufferCopy node above it in draw order, which snapshots the backbuffer (copy_mode rect or viewport) so hint_screen_texture reads are defined at that point.
  • CanvasGroup renders its children as one image first: fade a whole multi-sprite character with a single self_modulate alpha (no per-part overlap artifacts), or run one outline/ silhouette shader over the merged shape (pad fit_margin so the outline isn’t clipped).

Hit-stop (freeze a few frames on impact, huge for perceived weight):

func hit_stop(seconds := 0.08) -> void:
Engine.time_scale = 0.0
# 4th arg ignore_time_scale=true so the timer still ticks while frozen (verified signature):
await get_tree().create_timer(seconds, true, false, true).timeout
Engine.time_scale = 1.0

Screen shake (on a Camera2D, decaying):

var _shake := 0.0
func add_shake(amount := 6.0) -> void: _shake = maxf(_shake, amount)
func _process(delta: float) -> void:
offset = Vector2(randf_range(-_shake, _shake), randf_range(-_shake, _shake))
_shake = move_toward(_shake, 0.0, 40.0 * delta)

Others, same spirit. Particle burst: a GPUParticles2D with one_shot=true, emitting=true at the event, freed on finish. Hit flash is a shader that mixes albedo→white by a uniform you tween 1→0 (plain modulate multiplies, so it can’t turn a textured sprite uniformly white). Screen flash is a full-rect ColorRect on a top CanvasLayer with alpha tweened down. Floating text is a Label that tweens up and fades. Sound on every action counts too, and even a placeholder beep sells the feedback.

A reusable stack (build once)#

A Juice autoload exposing hit_stop(), add_shake(), flash(), spawn_text() so any scene calls Juice.hit_stop() with no wiring; drive it from signals (HealthComponent.died → shake + flash; Coin.collected → pop + particles + sound). Keep game-feel constants as @exports on the entity so they tune in the inspector. See gdscript-architecture.md for the autoload-service pattern and level-design.md for which layer to add when.

Component pattern (health example)#

A reusable HealthComponent scene (root Node, a script), instanced under any entity:

class_name HealthComponent
extends Node
signal health_changed(current: int, max_health: int)
signal died
@export var max_health: int = 10
var health: int
func _ready() -> void:
health = max_health
func take_damage(amount: int) -> void:
health = clampi(health - amount, 0, max_health)
health_changed.emit(health, max_health)
if health == 0:
died.emit()

Instance it under the player/enemy (scene.instance or node.add --type HealthComponent once it has a class_name), then wire died to a handler with node.connect. The HUD connects to health_changed. Same idea for HurtboxComponent (an Area2D that detects a Hitbox and calls take_damage).

State machine#

Simple AI/player states: enum + match in _physics_process.

enum State { IDLE, CHASE, ATTACK }
var state: State = State.IDLE
func _physics_process(delta: float) -> void:
match state:
State.IDLE: _do_idle(delta)
State.CHASE: _do_chase(delta)
State.ATTACK: _do_attack(delta)

Three enemy/encounter shapes worth keeping (from shipped-shooter devlogs). Onboarding aggro has intro enemies idle until provoked (proximity or first shot), giving new players a low-pressure window. Boss phases gated by counts and thresholds, not timers: “after the turret fires 3 times, unlock the blast; below half HP, swap to ramming + homing” reads as telegraphed escalation and survives pause/lag where timelines drift. The charge ultimate takes N seconds of holding to charge, with a massive payoff (screen-wipe) at a real resource cost (1 HP), and one authored risk knob: either damage interrupts the charge or charging grants invulnerability, never both. Every number here is an @export.

For complex behavior, go node-based: a StateMachine node with a child Node per state; the machine calls enter()/update()/exit() and switches current. The full pattern, with the transition signal contract, is in topdown-2d.md. (Godot also has AnimationTree state machines for animation, reached with anim_tree.*; expression-driven recipe in platformer-2d.md.)

Turn-based match loop + CPU personalities#

For alternating-turn games (dice/card duels), the loop is a handful of flags, not a framework: _turn (“player”/“cpu”), per-side _done flags recomputed from board state each pass (played >= cap or hand empty), one _busy input lock, and a single _advance_turn() that resolves when both sides are done. Give the CPU a personality drawn per match, an enum of picking strategies over a shared gain(piece) function (greedy = max, balanced = median, adaptive = smallest that retakes the lead else shed the lowest, gambler = random). Personality decides which piece, and the rolls stay pure luck, so the CPU never rigs. Rank by gain, not raw value (a strike is worth min(strike, their_total), so never fire a -6 at an empty board), and estimate conservatively so the CPU holds its bombs. Gate narration on information: name the leader only when the mode doesn’t conceal totals. Full worked example in ui-polish-2d.md’s source study; UI feel in that doc’s juice grammar.

Entity families: standardize the root, share the hurtbox#

A war story that repeats across devlogs: enemies prototyped ad hoc end up with mixed roots (CharacterBody2D here, Area2D there, StaticBody2D somewhere else) and suddenly damage is inconsistent: some never register hits, some take double. Two rules prevent it:

  • One root type per family. Everything that shares behavior (“takes damage”) shares a root type and child layout, decided the moment the second variant exists. Prefer Area2D roots for enemies that don’t need physics resolution, which is cheaper than CharacterBody2D.
  • One generic hurtbox scene (an Area2D child) that only detects and forwards to the root’s take_damage(); the root owns health. One shared script, not N per-enemy copies. It is the same hit/hurt/damage component split as topdown-2d.md. Prototype fast and messy to find the fun, then run the consistency refactor once patterns emerge, and do run it.

Content as data: .tres resources or JSON sidecars#

The Principles bullet says stats and items live in data, and Resource types are the Godot-native way to do it: class_name EnemyData extends Resource with @export fields, one .tres per enemy, edited in the inspector, type-checked at load.

A shipped alternative is worth knowing: one JSON sidecar per content item, scanned at boot into an id-keyed dictionary, with a factory autoload instantiating from it. The scan walks a content root, parses each .json, resolves any path-valued field to a loaded resource once, and keys the record by "<type>_<id>". Callers ask the registry by id and never load by string.

extends Node
## Boot-time scan of the JSON sidecars under content/, keyed by "<type>_<id>".
## Path-valued fields are resolved to loaded resources once, here, so callers
## never load by string.
const ROOTS := {"enemy": "res://content/enemies"}
const RESOURCE_FIELDS := ["scene", "icon"]
var entries: Dictionary = {}
func _ready() -> void:
for type in ROOTS:
for path in _json_files(ROOTS[type]):
var record: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(path))
if record == null or not record.has("id"):
push_error("content sidecar %s has no id" % path)
continue
for field in RESOURCE_FIELDS:
if record.has(field):
record[field] = load(record[field])
entries["%s_%s" % [type, record["id"]]] = record
func get_record(uid: String) -> Dictionary:
return entries.get(uid, {}).duplicate(true)
func _json_files(root: String) -> Array[String]:
var found: Array[String] = []
for name in DirAccess.get_files_at(root):
if name.ends_with(".json"):
found.append(root.path_join(name))
for name in DirAccess.get_directories_at(root):
found.append_array(_json_files(root.path_join(name)))
return found

get_record hands back a deep copy, because a caller that mutates a shared record poisons every later spawn of that type. A companion factory autoload turns a record into a typed data object (EnemyData.new(record)), letting a sidecar name its own class in a "class" field, which is how one registry serves content with different behaviour.

Build (verified live). Two sidecars, one autoload, read back from the running game:

Terminal window
godot-mcp script create --path res://content/content_db.gd --content "..."
godot-mcp project add-autoload --name ContentDB --path res://content/content_db.gd
godot-mcp editor reload && godot-mcp scene play --mode main
godot-mcp runtime eval --code 'var db = get_node("/root/ContentDB")
var brute = db.get_record("enemy_brute")
emit({"keys": db.entries.keys(), "brute_health": brute["stats"]["max_health"],
"scene_is_resource": brute["scene"] is PackedScene})'
# { "keys": ["enemy_brute", "enemy_grunt"], "brute_health": 220, "scene_is_resource": true }

The trade, in four axes. JSON sidecars are git-diffable (a balance change is a readable diff, where a .tres diff is noisy), and moddable (a player drops a file in a folder and the scan finds it). The cost is that authoring happens in a text editor with no inspector, no dropdowns, and no autocompletion of field names, and that nothing is type-checked: a typo in a key is a runtime push_error at best and a silent default at worst. .tres inverts all four. Pick sidecars when content volume and moddability dominate, .tres when a designer is doing the authoring.

Either way, validate on load. The scan above rejects a record with no id; a project with many fields should check the required ones by name at boot rather than discovering a missing key during a fight.

Spawning & projectiles#

A Bullet is its own scene; the shooter instances it at runtime and gives it velocity. Don’t pool prematurely: pooling pays off only at hundreds of concurrent projectiles, so pool the player’s rapid-fire stream and not the boss’s occasional volley. Hitscan vs projectile is a feel choice: an instant raycast reads as “a real gun” (miniguns, rifles); a travelling node reads as energy/arcing shots.

Offscreen lifecycle (APIs verified live): give every projectile/pickup a VisibleOnScreenNotifier2D child and queue_free() on its screen_exited signal. That is the standard leak-proof despawn, with no manual bounds math. For persistent world objects that should sleep offscreen (patrolling enemies, animated props), VisibleOnScreenEnabler2D pauses the target’s processing automatically (enable_node_path, mode inherit/always/when-paused), which is free culling with no code. The same pair exists as VisibleOnScreenNotifier3D/Enabler3D.

Specialty 3D physics (teach-level; all reachable via node.add + node.set, verified): VehicleBody3D + one VehicleWheel3D per wheel is a complete arcade car. Per wheel set use_as_traction/use_as_steering, wheel_radius, suspension (suspension_travel, wheel_friction_slip); drive by writing engine_force/steering/brake on the body each frame. SoftBody3D turns a mesh into cloth/jelly: pin anchor vertices with set_point_pinned(i, true), and raise simulation_precision before blaming the solver.

@export var bullet: PackedScene # set in inspector to res://entities/bullet.tscn
func shoot(dir: Vector2) -> void:
var b := bullet.instantiate()
b.global_position = global_position
b.velocity = dir * 600.0
get_tree().current_scene.add_child(b)

The bullet queue_free()s on lifetime/collision. For many bullets, pool them (keep a free-list, hide+reuse instead of instance/free each shot).

Pickups & triggers (Area2D)#

extends Area2D
signal collected
func _ready() -> void:
body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node) -> void:
if body.is_in_group("player"):
collected.emit()
queue_free()

Build: node add --type Area2D, add a CollisionShape2D child with a shape. Make the shape generous, because tiny radii are nearly impossible to hit with simulated input.

Input setup#

Define actions once, drive by action (not raw keys) so remapping/controllers work:

input_map set-action --action jump --events '[{"type":"key","keycode":"KEY_SPACE"}]'
input_map set-action --action move_left --events '[{"type":"key","keycode":"KEY_A"}]'

Then in playtesting prefer input action --action jump over input key.

Mobile: TouchScreenButton (a 2D node, not a Control) fires an input-map action from a screen region and can multi-press alongside other touches. Set visibility_mode = TOUCHSCREEN_ONLY so on-screen controls vanish on desktop. Because it maps to actions, the same gameplay code serves both inputs.

UI / HUD is signal-bound, never polling#

CanvasLayerControl nodes. The HUD listens to gameplay signals and updates; it does not read player state every frame.

extends Control
@onready var bar: ProgressBar = %HealthBar
func bind(hp: HealthComponent) -> void:
hp.health_changed.connect(func(cur, mx): bar.value = float(cur) / mx * 100.0)

Lay out with anchors (node set-anchor --preset ...), style with theme.*. For UI motion that containers would otherwise stomp, Godot has offset_transform_* on Control (confirm with engine class-info --class Control --filter offset_transform).

Groups for broadcast#

add_to_group("enemies") # in _ready
get_tree().call_group("enemies", "pause")
for e in get_tree().get_nodes_in_group("enemies"): ...

Manage via node.set_groups / node.find_in_group.

Timers & cooldowns#

  • One-shot delay: await get_tree().create_timer(0.5).timeout.
  • Repeating/inspectorable: a Timer node with timeout connected.
  • Cooldown gate: var _can_fire := true flipped by a timer.

Animation#

  • 2D frames / property tracks: AnimationPlayer (animation.*). Spritesheet characters: scene2d add-animated-sprite --texture sheet.png --hframes 4 --vframes 4 --autoplay walk --animations '{"walk":{"frames":[0,1,2,3],"fps":8}}', where one call builds the SpriteFrames (frame indices are row-major over the grid).
  • Blending/state (idle↔run↔jump): AnimationTree + state machine (anim_tree.*).
  • Locomotion blend spaces are the other AnimationTree tool. A BlendSpace1D interpolates poses along one axis for speed-based idle→walk→run; a BlendSpace2D blends across a plane for 8-way top-down locomotion, where blend_position is the movement Vector2. Neighbouring clips cross-fade on their own; there are no per-clip transitions to author.
    • Build a 2D 8-way blend as the tree root. The anim_tree group authors blend spaces directly (--root-type blend_space_2d on create, then one set-blend-point per clip; every call verified live, walk_* clips already on an AnimationPlayer):
      anim-tree create --node-path . --anim-player AnimationPlayer --name Tree \
      --root-type blend_space_2d --sync true --min-space "Vector2(-1,-1)" --max-space "Vector2(1,1)"
      anim-tree set-blend-point --node-path Tree --animation idle --pos-x 0 --pos-y 0
      anim-tree set-blend-point --node-path Tree --animation walk_right --pos-x 1 --pos-y 0
      anim-tree set-blend-point --node-path Tree --animation walk_up --pos-x 0 --pos-y -1
      # ...left / down / four diagonals; anim-tree get-structure --node-path Tree reads them back
      scene save
      set-blend-point wraps the named clip in an AnimationNodeAnimation at the blend-space position (2D: --pos-x/--pos-y or --position "Vector2(x,y)"; 1D: --pos <float>). --sync true sets the AnimationNodeSync flag both blend spaces carry, locking the blended clips to one shared playback ratio so a slow walk and a fast run don’t foot-slide; 2D --auto-triangles (on by default) triangulates the points for you. The 1D form is the same with --root-type blend_space_1d and float --min-space/--max-space (0→top speed). remove-blend-point --index N drops one. To nest the blend space inside a state machine instead of as the root, add-state --state-type blend_space_2d --state-name Locomotion, then target it with set-blend-point --blend-space-state Locomotion.
    • Drive blend_position from velocity in _physics_process, the one axis a blend space exposes as a runtime parameter:
      @onready var _tree: AnimationTree = $Tree
      func _physics_process(_delta: float) -> void:
      var dir := Input.get_vector("move_left", "move_right", "move_up", "move_down")
      velocity = dir * speed
      move_and_slide()
      if dir != Vector2.ZERO: # hold last heading when stopped
      _tree.set("parameters/blend_position", dir) # 2D: Vector2
      # 1D speed variant: _tree.set("parameters/blend_position", velocity.length())
      The path is parameters/blend_position when the blend space is the tree root. Nest it instead as a state named Locomotion in the state machine (so jump/attack still transition around it) and it becomes parameters/Locomotion/blend_position. Preview a pose at edit time with anim-tree set-parameter --node-path Tree --parameter blend_position --value "Vector2(1,0)"; confirm in-game with runtime eval --code 'emit(get_node("Tree").get("parameters/blend_position"))'.
  • 3D character motion stack (the SkeletonModifier3D family, children of the Skeleton3D, run after animation each frame; all node.add + node.set, APIs verified): LookAtModifier3D turns a bone toward a target_node (heads and eyes; set bone_name + forward_axis); TwoBoneIK3D plants limbs (indexed settings: setting_count = 1, then set_target_node(0, …) + set_pole_node(0, …) for the elbow/knee direction, so one modifier can drive several limbs); FABRIK3D/CCDIK3D/SplineIK3D solve longer chains (tentacles, spines). Secondary motion: SpringBoneSimulator3D makes tails/hair/cloth bones lag and jiggle (set_root_bone_name(0, …)/set_end_bone_name(0, …) per chain), with SpringBoneCollisionSphere3D/Capsule/Plane children keeping them out of the body. Ragdolls: PhysicalBoneSimulator3D + a PhysicalBone3D per major bone (generate via the editor’s Skeleton3D toolbar, then tune shapes); flip live with physical_bones_start_simulation() / stop, where death = animation off, simulation on. Modifier order matters: they apply top-to-bottom, so IK before spring bones.
  • Cutout / skeletal 2D (verified live): skeleton create-2d --bones '[{"name":"shoulder","position":[0,0]},{"name":"elbow","parent":"shoulder","position":[96,0]}]' builds the Bone2D chain with rests; skeleton skin-2d --node-path Rig --polygon-path Arm auto-weights a Polygon2D by inverse distance (--max-influences, --falloff; explicit --weights per bone also accepted). Pose bones with plain node.set on rotation_degrees, bake a new bind pose with set_rest_2d, animate bone rotations with animation.* tracks. Chain-tip bones without children warn about length auto-calc, which is expected; set length if it matters.
  • Juice (squash/stretch, punches, shake, hit-stop) is built with tweens; see Game feel vs juice above. Distinct from game feel (the control itself).

Positional audio (2D & 3D)#

AudioStreamPlayer is flat (music, UI); AudioStreamPlayer2D pans and attenuates by distance to the listener (the current Camera2D, or an AudioListener2D). The knobs that matter (verified live): max_distance (beyond it, silent; the default 2000px is small for zoomed-out games), attenuation (falloff exponent), panning_strength, bus (route world SFX to their own bus for the pause-menu duck, with audio.* commands managing buses), and max_polyphony (one player can voice N overlapping shots, so no per-shot player nodes). Positional audio is spatial information: an offscreen enemy you can hear approaching is gameplay, not polish.

3D (AudioStreamPlayer3D) swaps the model: attenuation_model (inverse / inverse-square / logarithmic / disabled) with unit_size scaling how fast falloff bites, max_distance as a hard cutoff (0 = audible forever, so set it; it’s also a perf cull), and optional doppler_tracking for fast movers. Same bus/polyphony discipline as 2D.

Scene management#

A SceneManager autoload that wraps get_tree().change_scene_to_file("res://...") (with a fade) centralizes transitions. Register it with project add-autoload.

Save / load#

Small saves: a Resource or JSON written to user://. Resource approach: a SaveData class_name extends Resource, ResourceSaver.save(data, "user://save.tres").

Networking over HTTP (leaderboards, telemetry)#

HTTPRequest is a node, not a blocking call: add one, fire request(), react to the request_completed signal. Signature (verified): request_completed(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray), and request(url, custom_headers, method, body) returns an error you must check: a malformed URL never reaches the socket, so the signal never fires. Wrap it in a thin autoload with a serial queue (one request in flight); leaderboard posts and telemetry pings don’t need concurrency, and a queue makes ordering and retries trivial:

extends Node # autoload "Net"
signal completed(tag: String, ok: bool, data: Variant)
var _http: HTTPRequest
var _queue: Array[Dictionary] = []
var _busy := false
func _ready() -> void:
_http = HTTPRequest.new()
_http.use_threads = true # desktop: run the transfer off the main thread
_http.timeout = 10.0 # give up after 10s instead of hanging forever
add_child(_http)
_http.request_completed.connect(_on_completed)
func post_json(url: String, payload: Dictionary, tag := "") -> void:
_queue.push_back({"url": url, "body": JSON.stringify(payload), "tag": tag})
_pump()
func _pump() -> void:
if _busy or _queue.is_empty():
return
_busy = true
var job: Dictionary = _queue.front()
var headers := PackedStringArray(["Content-Type: application/json"])
var err := _http.request(job["url"], headers, HTTPClient.METHOD_POST, job["body"])
if err != OK:
_finish(false, null) # never left the socket: drain and move on
func _on_completed(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
var ok := result == HTTPRequest.RESULT_SUCCESS and code >= 200 and code < 300
var data: Variant = null
if ok:
data = JSON.parse_string(body.get_string_from_utf8()) # null on malformed JSON, so check it
_finish(ok, data)
func _finish(ok: bool, data: Variant) -> void:
var job: Dictionary = _queue.pop_front()
_busy = false
completed.emit(String(job.get("tag", "")), ok, data)
_pump() # next in line

Build: project add-autoload --name Net --path res://systems/net.gd, then call Net.post_json("https://api.example.com/score", {"name": n, "score": s}, "leaderboard") and listen on Net.completed. HTTPS just works on desktop. Godot ships Mozilla’s CA bundle, so https:// validates against system certs with no setup (set_tls_options is only for certificate pinning or a private CA). use_threads keeps the transfer off the main thread on desktop; on other platforms confirm threading before enabling it.

Measuring performance (frame-delta, not a single readout)#

A single FPS / frame-time sample is noisy and misleading. Measure over a window: count frames advanced across a known interval and report an average and a worst frame. Be explicit about which world you’re sampling, because the running game and the editor viewport behave differently, and only the game’s number matters for “does it run well.”

Build (sample the running game over a window):

scene play --mode main
# capture_frames already advances N frames at a fixed interval. Use it as the window:
runtime capture-frames --count 60 --frame-interval 1
# read engine timing from inside the game over that window:
runtime eval --code '
var fps = Engine.get_frames_per_second()
var draw = RenderingServer.get_rendering_info(RenderingServer.RENDERING_INFO_DRAW_CALLS_IN_FRAME)
var prims = RenderingServer.get_rendering_info(RenderingServer.RENDERING_INFO_TOTAL_PRIMITIVES_IN_FRAME)
emit({"fps": fps, "draw_calls": draw, "primitives": prims})'
scene stop

Sample several times and keep the worst frame, not just the mean, because a stutter is what players feel. The profiling group exposes deeper monitors; confirm exact names with engine class-info --class Performance / engine search --query RENDERING_INFO against the live build before relying on them (the constants evolve between engine releases). Don’t trust the editor viewport’s frame rate as the game’s.

Common mistakes to avoid#

  • One giant scene + one 1000-line script. Split into entity scenes + components.
  • Movement/physics in _process instead of _physics_process.
  • Polling another node’s state every frame instead of connecting a signal.
  • get_node("../../Thing") chains. Use @export/%unique/groups.
  • Hard-coding values in _ready() that should be @exports.
  • Untyped GDScript.
  • Forgetting delta (frame-rate-dependent movement).
  • Confusing game feel (the control) with juice (the feedback), or shipping a prototype with neither. Game feel goes in the movement code; juice is fired on signals.
  • Trusting remembered API signatures. Confirm against the running engine with engine class-info/search.
Built with the help of godot-mcp. MIT licensed.