Quick answer: GDScript lambdas capture variables by reference, not by value. When you create lambdas in a loop, they all share a reference to the same loop variable and will all use its final value. Fix this by using bind() to pass the current value at creation time, or by extracting the lambda into a helper function that takes the value as a parameter.

You’re connecting signals in a loop — maybe wiring up a row of inventory buttons or a list of menu items. Each lambda should capture the current index so it knows which button was pressed. But when you click any button, they all report the same index: the last one. Every lambda captured the same variable, and by the time any of them fires, the loop is long finished. This is one of the most confusing GDScript behaviors for developers coming from languages with value-capture closures.

Understanding the Problem

Here’s the classic pattern that triggers the bug:

# Broken: all buttons will print the last index
func setup_buttons():
    for i in range(5):
        var button = buttons[i]
        button.pressed.connect(func(): print("Pressed button ", i))

You’d expect clicking button 0 to print “Pressed button 0”, button 1 to print “Pressed button 1”, and so on. Instead, every button prints “Pressed button 4” — the final value of i after the loop completes.

This happens because GDScript lambdas capture variables by reference. The lambda doesn’t copy the value of i when it’s created. Instead, it holds a reference to i itself. All five lambdas share the same reference. When any lambda eventually executes (in response to a button press), it reads the current value of i, which is 4 because the loop has already finished.

This behavior is common across many languages. Python has the same issue with closures in loops. JavaScript had it with var before let was introduced with block scoping. The mental model to remember: a lambda captures the variable, not its value at the moment of creation.

Fix 1: Use bind() on Callable

The cleanest workaround in GDScript is to use bind() on a Callable to pass the current value as a bound argument. Bound arguments are evaluated immediately, so each lambda gets its own copy of the value:

# Fixed: each button captures its own index via bind()
func setup_buttons():
    for i in range(5):
        var button = buttons[i]
        button.pressed.connect(_on_button_pressed.bind(i))

func _on_button_pressed(index: int):
    print("Pressed button ", index)

When bind(i) is called, it evaluates i immediately and stores the resulting value (0, 1, 2, 3, or 4) as a bound argument. Each connection gets its own bound value, independent of what happens to i afterward.

This approach has an additional benefit: it moves the callback logic into a named function, which is easier to debug and test. You can set breakpoints in _on_button_pressed and inspect the index parameter directly.

Fix 2: Factory Function Pattern

If you want to keep using inline lambdas, extract the lambda creation into a separate function. Function parameters create a new scope, so each call to the factory function captures its own copy of the value:

# Fixed: factory function creates a new scope for each lambda
func setup_buttons():
    for i in range(5):
        var button = buttons[i]
        button.pressed.connect(_make_callback(i))

func _make_callback(index: int) -> Callable:
    return func(): print("Pressed button ", index)

When _make_callback(i) is called, the value of i is copied into the index parameter. The returned lambda captures index — which is a local variable unique to that function call — not i. Each lambda gets its own index with a different value.

This pattern is particularly useful when the lambda needs to capture multiple values from the loop or when the callback logic is more complex than a single function call.

Common Scenarios Where This Bites You

The loop-variable capture problem shows up frequently in game UI code. Here are the most common scenarios and how to handle each:

Inventory grids: Creating clickable item slots in a loop where each slot needs to know its grid position.

# Broken
for x in range(grid_width):
    for y in range(grid_height):
        slot.gui_input.connect(func(event):
            if event is InputEventMouseButton:
                select_slot(x, y))  # Always selects last slot

# Fixed
for x in range(grid_width):
    for y in range(grid_height):
        slot.gui_input.connect(_on_slot_input.bind(x, y))

func _on_slot_input(x: int, y: int, event: InputEvent):
    if event is InputEventMouseButton:
        select_slot(x, y)

Tween sequences: Creating a chain of tweens where each step uses a different target value from an array.

# Broken - all tweens use the last target
for target in target_positions:
    tween.tween_callback(func(): move_to(target))

# Fixed - bind each target value
for target in target_positions:
    tween.tween_callback(_move_to_target.bind(target))

func _move_to_target(pos: Vector2):
    move_to(pos)

Dynamic signal connections: Connecting signals from spawned objects where each connection needs to identify which object fired.

# Broken
for enemy in spawned_enemies:
    enemy.died.connect(func(): on_enemy_died(enemy))  # Always last enemy

# Fixed
for enemy in spawned_enemies:
    enemy.died.connect(on_enemy_died.bind(enemy))

func on_enemy_died(which_enemy: Node):
    print(which_enemy.name, " was defeated")

If you hit this issue in production and players report incorrect UI behavior — like clicking one inventory slot but a different one activating — it can be very confusing to debug without understanding closure semantics. Log these issues with precise reproduction steps. Using a bug tracker like Bugnet that captures the game state at the moment of the report helps you identify which loop-created callback misfired.

Closures capture the variable, not the value. If you remember one thing about GDScript lambdas, let it be that.