Functions & Callable

Hi everyone ✋

In the previous post we went through operators, conditionals, loops, and match ─ and we ended with a state machine cycling a sprite through three behaviours.

Today we do functions.

The declaration syntax will take about four minutes 🙂 The rest of the post is the interesting part, because GDScript takes away something we have relied on for years ─ there is no method overloading ─ and then hands us something in return that most of our languages made us wait years for.

Here is the plan ─

  • Declaring functions, with types and default arguments.
  • No overloading. Why, and the three things to do instead.
  • static functions and variables.
  • Callable ─ functions as values, lambdas, and bind().

And we will finish with several sprites each moving to a different function 🥳

Let’s take a deep dive 🤿

Declaring a function

We have been writing these since post two. Here is the full shape ─

func take_damage(amount: int) -> void:
    health -= amount


func get_health_percent() -> float:
    return float(health) / float(max_health) * 100.0

Four parts, and only one of them is a surprise ─ the func keyword, the parameter list with type hints after each colon, the return type after ->, and then a colon opening the indented body.

-> void means the function returns nothing. Writing it is optional and we should write it anyway, for the same reason we type our variables ─ it lets Godot catch us if we later add a return something by mistake.

A few small rules worth knowing up front ─

# snake_case is the convention, and the engine uses it everywhere
func calculate_final_score() -> int:
    return 0


# a bare return exits early
func take_damage(amount: int) -> void:
    if is_invincible:
        return

    health -= amount


# a function that does nothing yet still needs a body
func not_written_yet() -> void:
    pass

That last one catches people. An empty body is a parse error, because indentation is the block structure and there is nothing to indent. pass is the placeholder that keeps the parser happy.

Default arguments

Parameters can carry defaults, and they work the way we expect ─

func spawn_enemy(kind: String, level: int = 1, is_boss: bool = false) -> void:
    print(kind, " lv", level, " boss=", is_boss)


spawn_enemy("goblin")                 # goblin lv1 boss=false
spawn_enemy("orc", 5)                 # orc lv5 boss=false
spawn_enemy("dragon", 20, true)       # dragon lv20 boss=true

One rule ─ defaults must come last. Once a parameter has a default, every parameter after it needs one too, because otherwise the caller could not skip it.

And one absence to note ─ there are no named arguments. We cannot write spawn_enemy("orc", is_boss = true) to skip the middle one. Positional only, so if we want the third default we have to supply the second.

What we want C++ / Java / C# GDScript
Declare a function void foo(int x) { } func foo(x: int) -> void:
Return a value Type before the name Type after ->
Returns nothing void -> void, optional but write it
Naming convention camelCase or PascalCase snake_case
Empty body { } pass
Default arguments C++ and C# yes, Java no Yes, and they must come last
Named arguments C# yes Does not exist
Overloading Yes Does not exist ─ see below

No method overloading 😱

Here is the one that will genuinely change how we write code.

In C++, Java, or C# we write this without thinking ─

// perfectly normal in Java
void attack() { ... }
void attack(int damage) { ... }
void attack(int damage, String element) { ... }

Three functions, one name, chosen by signature. GDScript cannot do this. If we try ─

func attack() -> void:
    pass

# ERROR ─ function "attack" already exists in this class
func attack(damage: int) -> void:
    pass

Not a warning. A hard parse error. A name means exactly one function in a class, and that is the end of it.

Why? Because overload resolution needs the compiler to know every argument’s type at the call site ─ and GDScript’s types are optional, so it frequently does not. A language where var x = get_thing() is legal cannot reliably pick between attack(int) and attack(String). Making typing optional and keeping overloads are mutually exclusive, and Godot chose optional typing.

So what do we do instead? Three options, in the order to reach for them.

1. Default arguments

Most overload sets in real code are just “the same function, with fewer arguments.” Those collapse into one function ─

# instead of three overloads
func attack(damage: int = 10, element: String = "physical") -> void:
    print("hit for ", damage, " ", element)


attack()                    # hit for 10 physical
attack(25)                  # hit for 25 physical
attack(25, "fire")          # hit for 25 fire

This covers the large majority of cases, and honestly it produces a nicer API than the overload set did.

2. Different names

When the behaviours genuinely differ, say so ─

func attack_melee(damage: int) -> void:
    pass


func attack_ranged(damage: int, distance: float) -> void:
    pass

This feels like a step backwards for about a week. Then we notice that attack_ranged() tells us more at the call site than attack() with three arguments ever did. Godot’s own API leans on this heavily ─ the engine has get_node() and get_node_or_null() rather than one overloaded get_node().

3. Accept a Variant and branch

The last resort, for when one function really must take different types ─

func set_target(target) -> void:
    if target is Vector2:
        move_towards(target)
    elif target is Node2D:
        move_towards(target.position)
    elif target is String:
        move_towards(find_by_name(target).position)

Note the deliberately untyped parameter ─ leaving the type off is what allows anything through. This is the one place in the series where untyped is the right call rather than laziness.

Use it sparingly though. We have moved a check the parser could have done for us into runtime, which is exactly the trade we spent post three arguing against 🤔

Coming-from note 🤔 ─ there is also no default-argument-based constructor overloading, because there is no constructor in the C++ sense either. Scripts have _init(), and there is only one of it. Objects that need several construction paths get static factory functions instead ─ which is the next section.

static ─ functions without an instance

A static function belongs to the script rather than to any object made from it ─

static func clamp_health(value: int, maximum: int) -> int:
    return clampi(value, 0, maximum)

The rule is the familiar one ─ a static function has no self, so it cannot touch instance variables or call instance methods. It only sees its own parameters and other static members.

Since Godot 4.1 variables can be static too ─

static var enemies_killed := 0


static func record_kill() -> void:
    enemies_killed += 1

One copy of that variable exists, shared by every instance of the script. Handy for counters and caches ─ and worth being careful with, since it is a global in a small hat.

The place static earns its keep is the factory pattern, which is how we work around having only one _init()

class_name Item
extends Resource

var name := ""
var value := 0


static func create_weapon(weapon_name: String) -> Item:
    var item := Item.new()
    item.name = weapon_name
    item.value = 100
    return item


static func create_potion() -> Item:
    var item := Item.new()
    item.name = "health potion"
    item.value = 25
    return item

Two construction paths, two clear names, no overloading needed. And Item.create_potion() reads better at the call site than a constructor with four arguments would 🙂

That class_name line is new ─ it registers the script under a global name so other scripts can say Item without loading a file path. We will do it properly in the OOP phase.

Callable ─ functions as values 🥳

Now the part that pays us back for losing overloads.

In GDScript, a function is a value. There is a built-in type for it, Callable, and we get one by naming a function without calling it ─

func greet() -> void:
    print("hello")


func _ready() -> void:
    var f: Callable = greet    # no parentheses ─ this is the function itself
    f.call()                   # hello

The parentheses are the whole distinction. greet is the function; greet() is the result of running it.

And note f.call(), not f(). A Callable is invoked through its call() method, which looks slightly clumsy at first and then stops mattering.

Because a Callable is a value, it goes anywhere a value goes ─ into variables, into arrays and dictionaries, into parameters, out of return statements ─

func apply_twice(f: Callable, value: int) -> int:
    return f.call(f.call(value))


func double(x: int) -> int:
    return x * 2


func _ready() -> void:
    print(apply_twice(double, 5))   # 20

Lambdas

We can also write a function inline, with no name ─

var triple := func(x: int) -> int:
    return x * 3

print(triple.call(4))   # 12

Same func keyword, just with the name left out. Two things to remember about lambdas specifically ─ they always need an explicit return to produce a value, and they cannot be declared static.

Where they really earn their place is the array methods ─

var scores := [42, 17, 93, 8, 66]

var high := scores.filter(func(s): return s > 40)
print(high)                                        # [42, 93, 66]

var doubled := scores.map(func(s): return s * 2)
print(doubled)                                     # [84, 34, 186, 16, 132]

var total := scores.reduce(func(a, b): return a + b, 0)
print(total)                                       # 226

scores.sort_custom(func(a, b): return a > b)
print(scores)                                      # [93, 66, 42, 17, 8]

That is filter, map, reduce, and a custom sort ─ available on every plain Array, with no library import.

Lambdas also capture variables from the scope around them ─

func _ready() -> void:
    var multiplier := 10
    var scale_it := func(x: int) -> int:
        return x * multiplier

    print(scale_it.call(5))   # 50

Worth knowing precisely how that capture works, because it is not the same for everything. A local variable is copied when the lambda is created, so changing it afterwards does not affect the lambda. A script-level variable is not copied ─ the lambda reads the current value each time it runs. That distinction produces confusing bugs if we assume one and get the other 👀

bind() ─ locking in arguments

A Callable can have arguments pre-attached ─

func log_event(message: String, severity: String) -> void:
    print("[", severity, "] ", message)


func _ready() -> void:
    var warn := log_event.bind("WARNING")
    warn.call("low health")     # [WARNING] low health
    warn.call("out of ammo")    # [WARNING] out of ammo

One detail matters enormously here ─ bind() appends its arguments to the end, not the front. So log_event.bind("WARNING") then called with "low health" becomes log_event("low health", "WARNING"). The bound value fills the last slot.

This bites when the parameter order feels backwards from how we bound it, so it is worth reading a signature twice before binding. There is unbind() too, for dropping trailing arguments a caller will pass but our function does not want.

Coming-from note 🤔Callable is a function pointer that knows which object it belongs to, so it is closer to C#’s delegate or a C++ std::function than to a raw function pointer. bind() is partial application ─ the same idea as std::bind, and considerably less painful to write. If we are coming from Godot 3, this replaces FuncRef entirely.

And the reason all of this matters more than it looks ─ signals take a Callable. Every button.pressed.connect(on_pressed) we write in Phase 3 is passing a function as a value. Today’s section is the groundwork for that one.

Mini-project ─ one loop, four behaviours 🛸

Let’s use functions-as-values for the job they are best at ─ removing a branch.

We are going to put four icons on screen, each moving by a different rule, driven by a single loop with no if and no match in it.

Make a new scene with a plain Node2D as the root, attach a script, and use this ─

extends Node2D

const ICON := preload("res://icon.svg")

var sprites: Array[Sprite2D] = []
var movers: Array[Callable] = []
var time := 0.0


func _ready() -> void:
    add_mover(orbit.bind(140.0), Color.CYAN)
    add_mover(orbit.bind(70.0), Color.ORANGE)
    add_mover(figure_eight, Color.LIME_GREEN)
    add_mover(func(t: float) -> Vector2:
        return Vector2(sin(t * 2.0) * 220.0, 0.0),
        Color.MEDIUM_PURPLE)


func add_mover(mover: Callable, tint: Color) -> void:
    var sprite := Sprite2D.new()
    sprite.texture = ICON
    sprite.scale = Vector2(0.4, 0.4)
    sprite.modulate = tint
    add_child(sprite)

    sprites.append(sprite)
    movers.append(mover)


func orbit(t: float, radius: float) -> Vector2:
    return Vector2(cos(t), sin(t)) * radius


func figure_eight(t: float) -> Vector2:
    return Vector2(sin(t) * 200.0, sin(t * 2.0) * 100.0)


func _process(delta: float) -> void:
    time += delta
    var centre := get_viewport_rect().size / 2

    for i in sprites.size():
        sprites[i].position = centre + movers[i].call(time)

Press F6 🥳

Look at that _process(). Three lines, and it has no idea what any of the four behaviours are. It asks each Callable where its sprite should be and puts it there. Adding a fifth movement pattern means one more add_mover() line and nothing else ─ _process() never changes again.

The pieces doing the work ─

orbit.bind(140.0) and orbit.bind(70.0) are the same function producing two different behaviours. And notice the signature ─ orbit(t, radius), with the bound radius second, because bind() appends. When _process() calls .call(time), time lands in the first slot.

figure_eight is passed bare, with no parentheses ─ the function itself, not its result.

The fourth one is a lambda, written inline because it is two lines long and will never be reused. Defining it as a named function would have been more ceremony than it deserves.

preload("res://icon.svg") loads the texture once when the script is parsed rather than every time we need it. It is new here and we will cover resource loading properly in Phase 3.

Worth trying ─ swap Color.LIME_GREEN for something else, change a bind() radius, or add a fifth lambda. Every experiment is one line, in one place.

So what should we remember? 🤔

Let’s wrap up the key takeaways ─

  • Functions are func name(param: Type) -> ReturnType:, in snake_case, and an empty body needs pass.
  • Default arguments must come last, and there are no named arguments ─ positional only.
  • There is no method overloading. One name, one function, enforced as a parse error ─ because optional typing and overload resolution cannot coexist.
  • Replace overloads with default arguments first, distinct names second, and an untyped parameter with an is check only as a last resort.
  • static works on functions and, since 4.1, on variables. Static factory functions are how we get around having only one _init().
  • A function without parentheses is a Callable ─ a real value we can store, pass, and return. Invoke it with .call().
  • Lambdas are func(x): return ..., need an explicit return, cannot be static, and power filter, map, reduce, and sort_custom.
  • Captured locals are copied, captured script variables are live. And bind() appends arguments to the end.

If that all landed, the loss of overloading should feel like a fair trade ─ and Callable is the piece that everything in Phase 3 is built on.

Congratulations 🥳🥳🥳
We have four sprites on four paths, driven by a loop that knows none of them.

In the next post we will look at arrays and dictionaries ─ typed arrays, the packed array family and when they are worth it, and why dictionaries in Godot 4 remember the order we inserted things 😉

Happy Coding 💻 🎵

Leave a Comment

Your email address will not be published. Required fields are marked *