# Game patterns Buildable patterns mapped to command sequences: movement, game feel vs juice, components, state machines, HUD. # Godot game patterns, built with swallowtail 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 `). 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. ## Recommended build order for a new game 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 play` → `input.*` → `runtime.get`/`screenshot` → fix → repeat. 8. **Polish**. Animation, particles, audio, juice (tweens). ## Top-down movement (CharacterBody2D) ```gdscript 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 ```gdscript 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): ```gdscript 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): ```gdscript @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): ```gdscript @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): ```gdscript @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): ```gdscript 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): ```gdscript 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): ```gdscript 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): ```gdscript 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 `@export`s** 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: ```gdscript 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`. ```gdscript 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 `"_"`. Callers ask the registry by id and never load by string. ```gdscript extends Node ## Boot-time scan of the JSON sidecars under content/, keyed by "_". ## 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: ```sh swallowtail script create --path res://content/content_db.gd --content "..." swallowtail project add-autoload --name ContentDB --path res://content/content_db.gd swallowtail editor reload && swallowtail scene play --mode main swallowtail 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. ```gdscript @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) ```gdscript 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 `CanvasLayer` → `Control` nodes. The HUD listens to gameplay signals and updates; it does not read player state every frame. ```gdscript 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 ```gdscript 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 `). `--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: ```gdscript @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: ```gdscript 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 `@export`s. - 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`.