Typed Futures in GDScript
Reliable Asynchronous Programming in Godot
Futures, a type of Continuation, are a handy way to express that a given value isn’t immediately ready yet, but will be at some point. This is closely related to GDScript’s await syntax, which has major usability drawbacks. Maybe we can forge a better path.
Luckily in March of 2025 Godot introduced typed dictionaries…
Typed Futures
This is the main insight:
var result: Dictionary[Future, Output]Because GDScript is reference-counted, we can rely on reference equality to create a negative-case singleton:
class_name Future
static var Ready: Future = Future.new()
static func is_ready(result: Dictionary[Future, Variant]) -> bool:
return Future.Ready in result
static func get(result: Dictionary[Future, Variant]) -> Future:
return result.keys().pop_back()
func run(): # override this in a sub-class
passAs an example, consider the following function:
func do_turn() -> Dictionary[Future, Action]We can then access the data in a type-safe way like so:
var result = do_turn()
if Future.is_ready(result):
return result[Future.Ready] # data ready immediately
else:
var user_input_needed = Future.get(result) # data not ready
return await user_input_needed.run()Yes, it might not be the most optimized access, but this is useful when writing logic that must be reliable rather than performant (like core game rules), as we’ll see now…
Case Study
Consider a game like hearts in which both player and enemies are asked to play a card on their turn. We might imagine writing simple game loop like:
Go to next “entity” (player or enemy) in turn order.
Ask them to play a card (provide an instance of the Card class).
Repeat until everyone has played something.
We might start by attempting to abstract both enemies and player:
@abstract
class_name Entity
@abstract func play_card() -> ???But now we’re stuck on the return type.
We could write Card which would allow us to complete our Enemy implementation:
class Enemy extends Entity
func play_card() -> Card:
# the enemy plays randomly for now
self.cards.shuffle()
return self.cards.pop_back()Although now we run into trouble in the player code:
class Player extends Entity
signal on_player_select_card
func play_card() -> Card:
var card: Card = await on_player_select_card
return cardOur function is now async!
But that’s represented nowhere in the type signature!
Whenever we want to call this function we have to manually soothe the editor, because when everything is an Entity the async-ness of the function gets erased:
@warning_ignore("redundant_await")
var card: Card = await entity.play_card()But what’s worse is that any function that contains this is now async too! And callers of that function might not have expected an async function either! Tracing await usage like this is hellish, especially during a refactor, and one missed await will likely crash your game for you and your play testers.
Typed Futures to the Rescue
We could instead explicitly encode the async-ness in the function type.
To do this we first have to create our Future class:
class_name Future
static var None: Future = Future.new(func(): pass)
var callback: Callable
signal done
func _init(on_done: Callable) -> void:
self.callback = on_done
func run() -> Signal:
self.callback.call()
done.emit.call_deferred()
return done
class SelectCard extends Future:
# override run() and implement card selectionThen our enemy definition becomes a little more complex:
class Enemy extends Entity
func play_card() -> Dictionary[Future, Card]:
self.cards.shuffle()
return {
Future.Ready: self.cards.pop_back()
}But this allows us to implement our player:
class Player extends Entity
func _on_card_selected(card: Card) -> void:
self.played_card = card
func play_card() -> Dictionary[Future, Card]:
return {
SelectCard.new(self._on_card_selected): null,
}Hooray! Now the caller understands (in a type-safe way) when user input is needed.
A Simpler Game Loop
For a turn-based game like hearts, the main loop is simple:
Execute game rules until player input is needed
Get player input
Repeat
Typed Futures make this really easy:
while true:
# run game logic until input is needed
var future = game.game_loop()
# wait until all the animations are done
await Animations.settle()
# (possibly) ask for user input
await future.run()
# keep goingThis helps separate game logic from user input handling. Once we receive a need for input from anywhere in our game logic, we propagate it up to the main game loop where we can do some tidying up (e.g. letting animations settle) before asking the player what to do next.
Note that in this setup it can also be nice to exit the game loop just to let all the animations settle and temporally separate game events for the player.
In games where it’s applicable, this one input at a time structure is nicer than having to manage and conditionally suppress multiple callbacks from various (possibly irrelevant) input sources. If it’s not time for a player to do something, it will never become a Future.
