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
Resourcetypes (.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_nodechains. - Separate data (Resources) / logic (scripts on the owning node) / presentation (Sprite, AnimationPlayer, UI). Don’t put all three in one script.
Recommended build order for a new game#
project info; set viewport/window viaproject set-setting.- Input map first:
input_map set-actionfor every action (move_left,jump, …). - Player scene in its own
.tscn, with a movement script and collision. - 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, andnode setthen reachesPlayer/Sprite2D. - Core loop: enemies, pickups, win/lose, all via component scenes.
- UI/HUD, Control nodes bound to gameplay by signals.
- Playtest:
scene play→input.*→runtime.get/screenshot→ fix → repeat. - 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 Playerscene open --path res://entities/player.tscnnode 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 CircleShape2Dscript create --path res://entities/player.gd --extends CharacterBody2Dscript attach --node-path . --script-path res://entities/player.gdscene 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.0var 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.0const FRICTION := 2200.0func _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.1var _coyote := 0.0var _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 shortMidair (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 commitmentvar _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 -= 1Layering: 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 steerablevar rate := (ACCEL if is_on_floor() else air_accel) if dir != 0.0 else FRICTIONAsymmetric gravity (a snappier arc: float up, fall fast):
velocity.y += (fall_gravity if velocity.y > 0.0 else rise_gravity) * deltaCamera 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.wis the particle’s 0→1 age; sample authored 1D curve textures at that value (CurveTexturefor 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.zso instances desync). - Polar UV remap (
radius, anglefrom center, optional twist) turns any linear gradient into rings and radial shockwaves. - Screen distortion: offset
hint_screen_textureUVs 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 aBackBufferCopynode above it in draw order, which snapshots the backbuffer (copy_moderect or viewport) sohint_screen_texturereads are defined at that point. - CanvasGroup renders its children as one image first: fade a whole multi-sprite character
with a single
self_modulatealpha (no per-part overlap artifacts), or run one outline/ silhouette shader over the merged shape (padfit_marginso 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.0Screen shake (on a Camera2D, decaying):
var _shake := 0.0func 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 HealthComponentextends Nodesignal health_changed(current: int, max_health: int)signal died@export var max_health: int = 10var 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
Area2Droots for enemies that don’t need physics resolution, which is cheaper thanCharacterBody2D. - One generic hurtbox scene (an
Area2Dchild) that only detects and forwards to the root’stake_damage(); the root owns health. One shared script, not N per-enemy copies. It is the same hit/hurt/damage component split astopdown-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 foundget_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:
godot-mcp script create --path res://content/content_db.gd --content "..."godot-mcp project add-autoload --name ContentDB --path res://content/content_db.gdgodot-mcp editor reload && godot-mcp scene play --mode maingodot-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 Area2Dsignal collectedfunc _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#
CanvasLayer → Control nodes. The HUD listens to gameplay signals and updates; it does
not read player state every frame.
extends Control@onready var bar: ProgressBar = %HealthBarfunc 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 _readyget_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
Timernode withtimeoutconnected. - Cooldown gate:
var _can_fire := trueflipped 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 theSpriteFrames(frame indices are row-major over the grid). - Blending/state (idle↔run↔jump):
AnimationTree+ state machine (anim_tree.*). - Locomotion blend spaces are the other
AnimationTreetool. 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, whereblend_positionis the movementVector2. 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_treegroup authors blend spaces directly (--root-type blend_space_2doncreate, then oneset-blend-pointper clip; every call verified live,walk_*clips already on anAnimationPlayer):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 0anim-tree set-blend-point --node-path Tree --animation walk_right --pos-x 1 --pos-y 0anim-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 backscene saveset-blend-pointwraps the named clip in anAnimationNodeAnimationat the blend-space position (2D:--pos-x/--pos-yor--position "Vector2(x,y)"; 1D:--pos <float>).--sync truesets theAnimationNodeSyncflag 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_1dand float--min-space/--max-space(0→top speed).remove-blend-point --index Ndrops 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 withset-blend-point --blend-space-state Locomotion. - Drive
blend_positionfrom velocity in_physics_process, the one axis a blend space exposes as a runtime parameter:The path is@onready var _tree: AnimationTree = $Treefunc _physics_process(_delta: float) -> void:var dir := Input.get_vector("move_left", "move_right", "move_up", "move_down")velocity = dir * speedmove_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())parameters/blend_positionwhen the blend space is the tree root. Nest it instead as a state namedLocomotionin the state machine (sojump/attackstill transition around it) and it becomesparameters/Locomotion/blend_position. Preview a pose at edit time withanim-tree set-parameter --node-path Tree --parameter blend_position --value "Vector2(1,0)"; confirm in-game withruntime eval --code 'emit(get_node("Tree").get("parameters/blend_position"))'.
- Build a 2D 8-way blend as the tree root. The
- 3D character motion stack (the
SkeletonModifier3Dfamily, children of theSkeleton3D, run after animation each frame; allnode.add+node.set, APIs verified):LookAtModifier3Dturns a bone toward atarget_node(heads and eyes; setbone_name+forward_axis);TwoBoneIK3Dplants limbs (indexed settings:setting_count = 1, thenset_target_node(0, …)+set_pole_node(0, …)for the elbow/knee direction, so one modifier can drive several limbs);FABRIK3D/CCDIK3D/SplineIK3Dsolve longer chains (tentacles, spines). Secondary motion:SpringBoneSimulator3Dmakes tails/hair/cloth bones lag and jiggle (set_root_bone_name(0, …)/set_end_bone_name(0, …)per chain), withSpringBoneCollisionSphere3D/Capsule/Planechildren keeping them out of the body. Ragdolls:PhysicalBoneSimulator3D+ aPhysicalBone3Dper major bone (generate via the editor’s Skeleton3D toolbar, then tune shapes); flip live withphysical_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 theBone2Dchain with rests;skeleton skin-2d --node-path Rig --polygon-path Armauto-weights aPolygon2Dby inverse distance (--max-influences,--falloff; explicit--weightsper bone also accepted). Pose bones with plainnode.setonrotation_degrees, bake a new bind pose withset_rest_2d, animate bone rotations withanimation.*tracks. Chain-tip bones without children warn about length auto-calc, which is expected; setlengthif 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: HTTPRequestvar _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 lineBuild: 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 stopSample 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
_processinstead 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.