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>) — patterns below are stable,
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. - 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 — its own
.tscn, movement script, collision. - One level scene — instance the player, build the world (TileMapLayer / 3D meshes).
- Core loop — enemies, pickups, win/lose, 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()For how the platformer actor and level are assembled — component actor, physics-driven
AnimationTree, moving platforms, cameras, collision layers, scene-tile blockouts — read
platformer-2d.md; this file owns the movement code and its feel.
Game feel vs juice — two different layers#
They are not the same thing, and they live in different code. Conflating them is the common mistake.
- Game feel is the quality of control — 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 (it’s core mechanics — belongs in the designer greybox); add juice second (cheap,
works on grey cubes — 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 — make 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 — 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 — gaps the first jump
almost clears, layered vertical routes, rescue ledges under hazards.
Air control (steering after takeoff — 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 (snappier arc — float up, fall fast):
velocity.y += (fall_gravity if velocity.y > 0.0 else rise_gravity) * deltaCamera that follows with weight — 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 — make the action satisfying (presentation, fired on events)#
None of these need final art — they work on cubes/spheres. Fire them from gameplay signals.
Squash & stretch (the workhorse — jump, land, hit, 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 — artists tune curves, not code. - Grayscale + LUT. Particle textures are grayscale masks; color comes from
texture(lut, mask.rr)— one texture serves every palette, recolored per effect. - Erosion dissolve:
smoothstep(threshold, threshold + softness, noise.r)with the threshold read from the lifetime curve — 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 — 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 — it 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 — 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 — a full-rect ColorRect
on a top CanvasLayer, alpha tweened down. Floating text — a Label that tweens up and fades.
Sound on every action — 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
— 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 — hold N seconds to
charge, massive payoff (screen-wipe) at a real resource cost (1 HP), with 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 — full pattern with the
transition signal contract in topdown-2d.md. (Godot also has AnimationTree state
machines for animation — use 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; the rolls stay pure luck — the CPU never rigs. Rank by gain, not raw
value (a strike is worth min(strike, their_total) — 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 — 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 — 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 — but do run it.
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 — pool the
player’s rapid-fire stream, 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 — the
standard leak-proof despawn, 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) —
free culling, 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), 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 — 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 — 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}}'— 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 — speed-based idle→walk→run; a BlendSpace2D blends across a plane — 8-way top-down locomotion,blend_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 — it locks 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/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 — 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— 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 — expected; setlengthif it matters. - Juice (squash/stretch, punches, shake, hit-stop): 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 — 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 — audio.* commands manage buses), and
max_polyphony (one player can voice N overlapping shots — 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 — 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 — 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 — 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 — and be explicit about which world you’re sampling: 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 — 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.