../

GDScript

GDScript for Godot 4.x (current stable 4.7) from a TypeScript developer's point of view: syntax, static typing, classes, properties, annotations, strings, signals and await, errors, the built-in math types and the style guide. The node model is in Nodes, scenes & signals; gameplay code in Input & physics.

GDScript vs TypeScript

ConceptTypeScriptGDScript
Blocksbraces: + indentation (tabs by convention)
Variablelet x = 1 / const X = 1var x := 1 / const X := 1
Type annotationlet x: numbervar x: int / var x: float
Inferenceautomaticonly with := (plain = is Variant)
Anyany / unknownuntyped / Variant
NullabilityT | nullonly Object types can be null; int never is
Arraynumber[]Array[int], or PackedInt32Array
RecordRecord<string, number>Dictionary[String, int] (4.4+)
Functionfunction f(a: number): numberfunc f(a: int) -> int:
Arrow fn(x) => x * 2func(x): return x * 2
Classclass A extends B (one per file or many)one class per file: class_name A extends B
Interfaceinterfacenone: duck typing, has_method, or @abstract (4.5+)
Constructorconstructor()func _init():
thisthisself (usually implicit)
Super callsuper.m()super.m() / super()
Getters/settersget x() / set x(v)var x: set = ..., get = ...
Enumenum E { A, B }enum E { A, B } (ints)
Template string`hi ${name}`"hi %s" % name, "hi {n}".format(...)
Equality===== (typed, no coercion between String and int)
Logical&& || !and or not (symbols also work)
Ternaryc ? a : ba if c else b
Exceptionstry/catchnone: Error return codes, assert, push_error
AsyncPromise, awaitsignals, await a signal or coroutine
EventsEventEmittersignal + .emit() / .connect()
Modulesimport / exportclass_name (global), preload("res://x.gd")
MemoryGCref counting (RefCounted), manual for Node (queue_free)

Syntax basics

player.gd
class_name Player
extends CharacterBody2D
## Doc comment: shown in the editor's help.
 
signal died
 
const MAX_HP := 100
 
@export var speed := 200.0
var hp := MAX_HP
 
func _ready() -> void:
    print("ready with ", hp, " hp")
 
func take_damage(amount: int) -> void:
    hp -= amount
    if hp <= 0:
        died.emit()
RuleDetail
Indentationtabs are the Godot style (snippets here use 4 spaces for width); never mix in one file
Comments# line comment; ## doc comment above a member or at the top of the class
Statementsone per line; ; allowed but unidiomatic
Line continuationinside () [] {} a line may break freely; elsewhere end the line with \
Empty bodypass
Script = classa .gd file is an anonymous class; class_name registers it globally
Default basea script without extends extends RefCounted
Entry pointnone: scripts attach to nodes; the engine calls _ready, _process, ...
Keywordsvar const func static class class_name extends signal enum await, is as in not and or, self super null true false, breakpoint preload

Variables, constants & enums

var a = 5            # Variant: can later hold a String
var b: int = 5       # typed, explicit
var c := 5           # typed, inferred as int
var d: float         # default 0.0 (int 0, bool false,
                     # String "", Object null)
const GRAVITY := 980.0
const DIRS: Array[Vector2] = [Vector2.UP, Vector2.DOWN]
static var instances := 0  # shared by all instances
 
enum State { IDLE, RUN, JUMP }       # State.IDLE == 0
enum Tier { LOW = 1, MID = 5, HIGH = 10 }
enum { UNNAMED_A, UNNAMED_B }        # plain int constants
 
var state: State = State.IDLE
Enum operationResult
State.RUN1
State.keys()["IDLE", "RUN", "JUMP"]
State.values()[0, 1, 2]
State.find_key(1)"RUN"
State.size()3
State as a typevar s: State; the inspector shows a dropdown when exported
Enum across filesPlayer.State.RUN (via class_name)

const must be a constant expression (literals, other constants, preload); arrays and dictionaries declared const are read-only.

Types & static typing

TypeNotes
booltrue / false
int64-bit signed
float64-bit double in GDScript (vectors hold 32-bit floats unless you build with double precision)
StringUTF-32 internally, immutable value semantics
StringNameinterned string, &"name"; cheap comparisons
NodePathpre-parsed path, ^"Path/To"
Array, Array[T]reference type, ordered, typed variant checks element types
Dictionary, Dictionary[K, V]reference type, keeps insertion order; typed form is 4.4+
Callable, Signalfirst-class function and signal references
Object and subclassesRefCounted (auto-freed), Node (tree-owned), Resource (ref-counted assets)
Variantany of the above; the default when no type is given
var names: Array[String] = ["ada", "lin"]
var scores: Dictionary[String, int] = {"ada": 3}
var enemies: Array[Enemy] = []           # custom class
var target: Node2D = null          # Objects may be null
 
var n := get_node("Sprite2D") as Sprite2D  # or null
if n is Sprite2D:                          # narrowing check
    n.flip_h = true
if n is not AnimatedSprite2D:              # 4.3+
    pass
 
for name: String in names:   # typed loop variable
    print(name.to_upper())
 
# Array.map returns an untyped Array; assign() converts
var lengths: Array[int] = []
lengths.assign(names.map(func(s): return s.length()))
Why type everythingDetail
Errors at parse timewrong method names, wrong argument types, missing returns
Autocompletionfull completion for typed values, including get_node(...) as T
Speedtyped code compiles to optimized opcodes (noticeably faster in hot loops)
Safe linesthe editor gutter marks lines whose types are fully known
Enforce itProject Settings > Debug > GDScript: set untyped_declaration to Warn or Error

Arrays & dictionaries

var a: Array[int] = [3, 1, 2]
a.append(4)          # push_back
a.insert(0, 9)
a.erase(9)           # remove first matching value
a.remove_at(0)       # by index
var last: int = a.pop_back()  # Variant return
a.sort()             # in place
a.sort_custom(func(x, y): return x > y)   # descending
var evens := a.filter(func(x): return x % 2 == 0)
var total: int = a.reduce(func(acc, x): return acc + x, 0)
var any_big := a.any(func(x): return x > 10)
print(a.has(2), a.find(2), a.size(), a.is_empty())
print(a.slice(1, 3), a.front(), a.back(), a.max())
var copy := a.duplicate()        # shallow; true for deep
var pick: int = a.pick_random()
 
var d := {"hp": 10, "name": "slime"}
d["hp"] -= 1
d.speed = 3.0              # dot access for String keys
print(d.get("armor", 0))   # default when missing
print(d.has("hp"), "hp" in d, d.keys(), d.values())
d.erase("speed")
d.merge({"xp": 5})         # overwrite=false by default
for key in d:
    print(key, " = ", d[key])
Packed arrayElementUse
PackedByteArraybytebinary data, FileAccess buffers, network
PackedInt32Array / PackedInt64Arrayinttile ids, indices
PackedFloat32Array / PackedFloat64Arrayfloatsamples, heights
PackedStringArrayStringsplit() results, file lists
PackedVector2Array / PackedVector3Arrayvectorpolygons, lines, mesh vertices
PackedColorArrayColorvertex colors
PackedVector4ArrayVector44.3+

Packed arrays are contiguous and far smaller than Array (which stores a Variant per slot). Engine APIs such as Line2D.points or Polygon2D.polygon expect them.

Operators

OperatorMeaning
+ - * /int / int is integer division: 7 / 2 == 3; use 7 / 2.0
%integer remainder (sign follows the left operand); fmod, fposmod, posmod for others
**power: 2 ** 10 == 1024
== != < > <= >=no coercion: 1 == "1" is never true; ordering mismatched types is an error
and or not / && || !short-circuit; operands need not be bool
& | ^ ~ << >>bitwise on int (collision layers, flags)
in / not inmembership in Array, Dictionary keys, String (substring)
is / is nottype test, works with built-ins, classes and class_name scripts
ascast; on Objects returns null on failure, on built-ins it converts or errors
a if c else bconditional expression
+= -= *= /= %= **= &= |= ^= <<= >>=compound assignment

Truthiness: 0, 0.0, "", [], {}, null and empty vectors are falsy. A freed object is not null: test with is_instance_valid(obj).

Control flow & match

if hp <= 0:
    die()
elif hp < 20:
    warn()
else:
    pass
 
for i in 5:                   # 0..4
    pass
for i in range(10, 0, -2):    # 10, 8, 6, 4, 2
    pass
for child in get_children():
    pass
for key in dict:              # keys; dict[key] for value
    pass
for ch in "hey":              # characters
    pass
 
while queue.size() > 0:
    var item = queue.pop_front()
    if item == null:
        continue
    if item.done:
        break
match value:
    0:
        print("zero")
    1, 2, 3:                         # several patterns
        print("small")
    State.RUN:                       # constants and enums
        print("running")
    [var x, var y]:                  # array, binds x and y
        print(x, y)
    [1, ..]:                         # open-ended array
        print("starts with 1")
    {"type": "hit", "dmg": var dmg}: # dictionary
        print(dmg)
    var n when n is int and n > 100: # guard with `when`
        print("big ", n)
    _:                               # wildcard / default
        print("other")

match compares with == after a type check (1 does not match 1.0), never falls through, and runs only the first matching branch.

Functions & lambdas

func add(a: int, b: int = 1) -> int:
    return a + b
 
static func clamp01(x: float) -> float:   # no self
    return clampf(x, 0.0, 1.0)
 
func sum(first: float, ...rest: Array) -> float:  # 4.5+
    var t := first
    for v in rest:
        t += v
    return t
 
func log_all(...args: Array) -> void:     # variadic
    print(" ".join(args.map(str)))
# Lambdas are Callables; single-line or indented block
var double := func(x: int) -> int: return x * 2
var greet := func(name: String) -> void:
    print("hi ", name)
    print("bye")
 
double.call(4)                 # 8
double.callv([4])              # args as an Array
var bound := add.bind(10)      # appends args: add(x, 10)
bound.call(1)                  # 11
var method := Callable(self, "add")  # dynamic by name
method.is_valid()
button.pressed.connect(func(): print("clicked"))
 
# Captures copy values at creation: reassigning a
# captured local does not reach the outer scope. Mutate
# a container instead.
var count := [0]
var inc := func(): count[0] += 1
Callable methodDoes
call(...) / callv(arr)invoke
call_deferred(...)invoke at the end of the frame (idle time)
bind(...) / bindv(arr)append extra args (signal-handler context)
unbind(n)drop the last n args (ignore signal parameters)
get_object(), get_method()target and method name
is_valid()target alive and method exists

Built-in functions are Callables too (4.3+): print.callv(["a", 1]).

Classes & inheritance

weapon.gd
@abstract                       # 4.5+: cannot .new()
class_name Weapon
extends Resource
 
@export var damage := 1
 
@abstract func fire(from: Vector2) -> void
 
func describe() -> String:
    return "%s (%d dmg)" % [get_class(), damage]
shotgun.gd
class_name Shotgun
extends Weapon
 
func _init(dmg := 4) -> void:   # constructor
    damage = dmg
 
func fire(from: Vector2) -> void:
    for i in 5:
        print("pellet from ", from)
 
func describe() -> String:
    return "Shotgun: " + super()   # parent method
# Inner classes: namespaced under the outer script
class Stats:                    # extends RefCounted
    var hp := 10
    var armor := 0
    func _init(h: int) -> void:
        hp = h
 
var s := Stats.new(20)          # outside: Player.Stats
var gun := Shotgun.new(6)
const EnemyScript := preload("res://enemy.gd")
var e = EnemyScript.new()      # no class_name needed
BaseLifetimeUse for
Objectmanual free()rare; low-level
RefCountedfreed when the last reference drops (cycles leak)plain data objects, helpers
Resourceref-counted, serializable, cached by pathdata assets (see Resources)
Nodefreed with its parent or queue_free()anything in the scene tree
ReflectionExample
Class nameobj.get_class() returns the engine class; obj.get_script().get_global_name() the class_name
Method existsobj.has_method("fire")
Dynamic callobj.call("fire", pos)
Dynamic propertyobj.get("hp"), obj.set("hp", 5)
Property listobj.get_property_list()
Weak referencevar w := weakref(obj), then w.get_ref()

Static members: static var and static func belong to the class. @static_unload lets the class's static data be freed when no instance remains.

Properties: setters & getters

signal health_changed(value: int)
 
@export var max_health := 100
 
var health := 100:
    set(value):
        health = clampi(value, 0, max_health)  # no recursion
        health_changed.emit(health)
    get:
        return health
 
var ratio: float:                 # computed, no storage
    get:
        return float(health) / max_health
 
var speed: float = 1.0: set = set_speed
 
func set_speed(v: float) -> void:
    speed = maxf(v, 0.0)
RuleDetail
Inside the setterassigning the property itself writes the backing field directly (no infinite loop)
Elsewhere in the classhealth = 5 and self.health = 5 both call the setter in 4.x
Exported + setterthe setter runs when the scene loads (before _ready), so guard node access with is_node_ready()
@tool scriptssetters also run in the editor, handy for live previews

Annotations

AnnotationEffect
@export var x := 1editable in the inspector, saved in the scene
@export_range(0, 100, 1, "or_greater")slider; extra hints "or_less", "exp", "suffix:px", "radians_as_degrees"
@export_enum("Warrior", "Mage")int or String dropdown
@export_flags("Fire", "Water")bit-flag checkboxes
@export_flags_2d_physicscollision-layer picker (_3d_physics, _2d_render ...)
@export_file("*.json"), @export_dirpath pickers (@export_global_file for absolute paths)
@export_multiline, @export_placeholder("...")text inputs
@export_color_no_alphacolor picker without alpha
@export_exp_easingeasing curve editor for a float
@export_node_path("Button")NodePath restricted to types; prefer @export var b: Button
@export_group("Movement"), @export_subgroup, @export_categoryinspector grouping
@export_storagesaved but hidden in the inspector (4.3+)
@export_custom(hint, "hint string")any PROPERTY_HINT_* (4.3+)
@export_tool_button("Bake", "Bake")inspector button calling a Callable, @tool only (4.4+)
@onready var s := $Sprite2Dassigned just before _ready
@toolscript runs in the editor (first line)
@icon("res://icons/enemy.svg")icon in the scene tree and create dialog
@warning_ignore("unused_parameter")silence one warning for the next statement or function
@static_unloadallow static data to be unloaded
@abstractabstract class or method (4.5+)
@rpc("any_peer", "reliable")multiplayer remote call
@tool
extends Node2D
 
@export_group("Shape")
@export_range(1.0, 500.0, 0.5, "suffix:px")
var radius := 32.0:
    set(v):
        radius = v
        queue_redraw()   # live preview in the editor
@export var color := Color.TOMATO
@export var resource: Weapon            # typed resource slot
@export var targets: Array[Node2D] = [] # node references
 
func _draw() -> void:
    draw_circle(Vector2.ZERO, radius, color)

In a @tool script, guard game-only code with if Engine.is_editor_hint(): return.

Strings & formatting

var s := "Hello"
var multi := """line 1
line 2"""
var raw := r"C:\no\escapes"      # raw string
var sn := &"jump"                # StringName literal
var np := ^"UI/HealthBar"        # NodePath literal
 
"%s has %d hp" % ["slime", 5]    # printf-style
"%.2f" % 3.14159                 # 3.14
"%05d" % 42                      # 00042
"%-8s|" % "ab"                   # left-aligned: "ab      |"
"%x" % 255                       # ff
"%*d" % [4, 7]                   # width from args: "   7"
"{0} vs {1}".format(["a", "b"])
"{name} lv{lvl}".format({"name": "Ada", "lvl": 3})
str(42) + " " + str(Vector2(1, 2))  # "42 (1, 2)"
String.num(3.14159, 2)           # "3.14"
MethodExample / result
length(), is_empty()"abc".length() == 3
to_upper(), to_lower(), capitalize()"move_left".capitalize() is "Move Left"
to_snake_case(), to_pascal_case(), to_camel_case()identifier conversion
split(",", false)PackedStringArray, skip empties
", ".join(arr)join (called on the separator)
strip_edges(), lstrip(chars), rstrip(chars)trim
begins_with(), ends_with(), contains(), "x" in stests
find(), rfind(), count()search; -1 when absent
replace(a, b), substr(from, len), left(n), right(n)slicing
to_int(), to_float(), is_valid_int(), is_valid_float()parsing
pad_zeros(n), lpad(n, " "), rpad(n)padding
path_join(), get_extension(), get_file(), get_base_dir(), get_basename()paths
sha256_text(), md5_text(), uri_encode(), uri_decode()hashing, URLs
s[0], s.unicode_at(0), String.chr(65)characters

print(a, b) concatenates, prints(a, b) separates with spaces, printt with tabs, print_rich("[b]x[/b]") uses BBCode in the Output panel.

Signals & await

signal hit(damage: int, source: Node)
signal died
 
func _ready() -> void:
    hit.connect(_on_hit)
    died.connect(func(): print("dead"), CONNECT_ONE_SHOT)
 
func _on_hit(damage: int, source: Node) -> void:
    print(source.name, " dealt ", damage)
 
func take(dmg: int, src: Node) -> void:
    hit.emit(dmg, src)
# await pauses this function until the signal fires
func flash() -> void:
    modulate = Color.RED
    await get_tree().create_timer(0.1).timeout
    modulate = Color.WHITE
 
# A function that awaits is a coroutine; await its result
func ask() -> bool:
    $Dialog.popup()
    var ok: bool = await $Dialog.answered  # 1 arg: value
    return ok
 
func _on_button_pressed() -> void:
    if await ask():
        print("confirmed")
    await get_tree().process_frame    # wait one frame
    await get_tree().physics_frame    # wait one tick
    await $Anim.animation_finished
FactDetail
Signal with several argsawait returns them as an Array
Calling a coroutine without awaitruns until its first await, then returns to the caller (fire and forget)
Freed emittera pending await on a freed object's signal never resumes
No Promise.allawait signals one by one, or count completions with a counter and a signal
Timersget_tree().create_timer(sec) is one-shot and cheap; use a Timer node for repeating

More on connections, flags and bind in Nodes, scenes & signals.

Errors & debugging

GDScript has no exceptions. A runtime error in a debug build pauses the game in the debugger; in a release build it is logged, the current function aborts, and the game carries on.

var err := ResourceSaver.save(res, "user://a.tres")
if err != OK:
    push_error("save failed: %s" % error_string(err))
    return
 
var f := FileAccess.open("user://x.txt", FileAccess.READ)
if f == null:                           # null on failure
    push_warning(str(FileAccess.get_open_error()))
 
assert(speed > 0.0, "speed must be positive")  # debug only
print_debug("reached")   # adds file:line (debug builds)
print_stack()            # current GDScript call stack
breakpoint               # keyword: pause here in debugger
ToolBehavior
Error enumOK (0), FAILED, ERR_FILE_NOT_FOUND, ERR_INVALID_PARAMETER, ERR_CANT_OPEN ...
error_string(err)readable name for an Error
assert(cond, msg)stripped from release exports; never put side effects inside
push_error(msg), push_warning(msg)Debugger > Errors tab plus stderr; execution continues
printerr(...)plain stderr
Nullable returnsmany APIs return null (get_node_or_null, FileAccess.open, load)
Result patternreturn {ok, value} Dictionaries or a small class when a failure needs data

Built-in math types

TypeKey members
Vector2, Vector2ix y, length(), normalized(), dot(), cross() (float), angle(), angle_to(), direction_to(), distance_to(), rotated(), lerp(), move_toward(), limit_length(), slide(), bounce(), reflect(), snapped()
Vector3, Vector3ias above plus cross() (Vector3), slerp(), signed_angle_to()
Vector4, Vector4ishader-style data
ConstantsVector2.ZERO ONE UP DOWN LEFT RIGHT; Vector3.FORWARD is (0, 0, -1)
Rect2, Rect2iposition size end, has_point(), intersects(), encloses(), grow(), get_center(), merge()
Transform2Dorigin, x y (basis axes), rotation, scale; * composes; affine_inverse()
Transform3Dbasis, origin; looking_at(), rotated(), translated(), orthonormalized()
Basis3x3 rotation/scale; x y z columns; forward is -basis.z; from_euler(), get_euler()
Quaternionslerp(), get_euler(); Basis(q) to convert
AABB, Plane3D bounds, plane tests
Colorr g b a in 0 to 1, Color("#ff8800"), Color.from_hsv(), lerp(), lightened(), darkened(), to_html(); named constants like Color.TOMATO
var dir := (target.global_position - global_position)
var n := dir.normalized()          # safe on ZERO: ZERO
var facing := Vector2.RIGHT.rotated(rotation)
position += n * speed * delta      # position.x += 1 works
look_at(get_global_mouse_position())  # Node2D helper
var local := to_local(world_point)    # and to_global()
 
var t := Transform3D.IDENTITY.looking_at(
    Vector3(0, 0, -5), Vector3.UP)
var fwd := -global_transform.basis.z  # 3D forward
var c := Color.WHITE.lerp(Color.RED, 0.5)

Built-in math types, String and Callable are value types (copied on assignment); Array, Dictionary and Objects are references. a.duplicate(true) deep-copies containers.

Math helpers

FunctionNotes
lerp(a, b, t)Variant: floats, vectors, colors; lerpf for typed floats
inverse_lerp(a, b, v), remap(v, a0, a1, b0, b1)map between ranges
move_toward(from, to, delta)step by at most delta; vectors have their own move_toward
clamp(v, lo, hi), clampf, clampityped variants avoid Variant returns
snappedf(v, step), snappediround to a grid
wrapf(v, lo, hi), wrapi, pingpong(v, len)cyclic values, angles
lerp_angle(a, b, t), angle_difference(a, b)shortest-path rotation
smoothstep(a, b, x), ease(x, curve)easing
deg_to_rad, rad_to_deg, PI, TAU, INF, NANangles are radians everywhere
absf, signf, floorf, ceilf, roundf, floori, roundityped versions of abs, sign ...
fmod, fposmod, posmodfloat remainder, always-positive remainder
is_equal_approx(a, b), is_zero_approx(x)float comparison
randf(), randi(), randf_range(a, b), randi_range(a, b), randfn(mean, dev)global RNG, auto-seeded at startup
seed(n)make the global RNG deterministic
RandomNumberGenerator.new()independent RNG with its own seed and state
arr.pick_random(), arr.shuffle()random element, in-place shuffle
# Frame-rate independent smoothing (exponential decay)
var k := 10.0
pos = pos.lerp(target, 1.0 - exp(-k * delta))

Style guide

ItemConvention
Filessnake_case.gd, snake_case.tscn
class_name, node namesPascalCase
Functions, variablessnake_case
Private membersleading _: _velocity, _on_timer_timeout
Constants, enum membersCONSTANT_CASE
Enum namesPascalCase
Signalspast tense: died, health_changed, door_opened
Signal handlers_on_<node>_<signal>: _on_start_button_pressed
Booleansis_, can_, has_ prefixes
Indentationtabs; two indents for continuation lines
Line lengtharound 100 characters
Quotesdouble quotes unless the string contains them
Numbers1.0 not 1. ; 0.5 not .5; 1_000_000 separators allowed

Code order in a script: @tool, class_name, extends, ## doc comment, signals, enums, constants, @export variables, public variables, private variables, @onready variables, _init, _enter_tree, _ready, _process, _physics_process, other virtuals, public methods, private methods, inner classes.

C# differences

GDScriptC# (.NET build of Godot)
extends Node2Dpublic partial class Player : Node2D (must be partial)
func _ready():public override void _Ready() (PascalCase API)
_process(delta) float_Process(double delta)
@export var speed := 200.0[Export] public float Speed { get; set; } = 200f;
$Sprite2DGetNode<Sprite2D>("Sprite2D")
signal died[Signal] public delegate void DiedEventHandler();
died.emit()EmitSignal(SignalName.Died)
died.connect(f)Died += F;
await timer.timeoutawait ToSignal(timer, Timer.SignalName.Timeout);
position.x = 3Position = Position with { X = 3 }; (structs returned by value)
print() / lerp()GD.Print() / Mathf.Lerp()
Array[int], DictionaryGodot.Collections.Array<int>, or plain .NET collections for internal data

C# needs the .NET editor build and cannot export to the Web in Godot 4.x; see Resources, saving & export.

Recipes

Enum state machine

Use for a small actor with a handful of states; switch to state nodes when states need their own data.

enum State { IDLE, RUN, ATTACK }
var state := State.IDLE
 
func _physics_process(delta: float) -> void:
    match state:
        State.IDLE:
            if Input.get_axis("left", "right") != 0.0:
                _enter(State.RUN)
        State.RUN:
            _move(delta)
            if Input.is_action_just_pressed("attack"):
                _enter(State.ATTACK)
        State.ATTACK:
            pass  # animation_finished returns to IDLE
 
func _enter(next: State) -> void:
    if next == state:
        return
    state = next
    $Anim.play(State.keys()[next].to_lower())

Cooldown without a Timer node

Use for abilities and weapons: compare engine time instead of counting down in _process.

@export var cooldown := 0.4
var _ready_at := 0.0
 
func try_fire() -> bool:
    var now := Time.get_ticks_msec() / 1000.0
    if now < _ready_at:
        return false
    _ready_at = now + cooldown
    _spawn_bullet()
    return true

Weighted random pick

Use for loot tables and spawn tables; weights need not sum to 1.

static func weighted_pick(
    items: Array, weights: PackedFloat32Array,
) -> Variant:
    var total := 0.0
    for w in weights:
        total += w
    var r := randf() * total
    for i in items.size():
        r -= weights[i]
        if r <= 0.0:
            return items[i]
    return items.back()
 
# weighted_pick(["common", "rare"], [9.0, 1.0])

Typed data class

Use for plain structured data that never touches the scene tree (inventory rows, AI blackboards).

item_stack.gd
class_name ItemStack
extends RefCounted
 
var id: StringName
var count: int
 
func _init(p_id: StringName, p_count := 1) -> void:
    id = p_id
    count = p_count
 
func _to_string() -> String:   # used by print() / str()
    return "%s x%d" % [id, count]
 
# var s := ItemStack.new(&"potion", 3); print(s)

Debounce a Callable

Use to collapse bursts of calls (text search, window resize) into one call after a quiet period.

var _gen := 0
 
func debounce(fn: Callable, wait := 0.3) -> void:
    _gen += 1
    var my_gen := _gen
    await get_tree().create_timer(wait).timeout
    if my_gen == _gen:        # nothing newer arrived
        fn.call()
 
func _on_search_text_changed(t: String) -> void:
    debounce(_run_search.bind(t))

Abstract strategy (4.5+)

Use when several interchangeable behaviors share one contract, such as AI targeting rules.

targeting.gd
@abstract
class_name Targeting
extends RefCounted
 
@abstract func pick(from: Node2D, all: Array) -> Node2D
nearest_targeting.gd
class_name NearestTargeting
extends Targeting
 
func pick(from: Node2D, all: Array) -> Node2D:
    var best: Node2D = null
    var best_d := INF
    for n: Node2D in all:
        var p := n.global_position
        var d := from.global_position.distance_squared_to(p)
        if d < best_d:
            best = n
            best_d = d
    return best
 
# var t: Targeting = NearestTargeting.new()
# Targeting.new() is an error (abstract)

References