Constness in GDScript

Const is something that predominantly only exists within the C family of languages. It's something I've grown used to over the years in relying on even if it is most of the time just an illusion, only not mutable through this specific reference.

Some languages try to be even stricter like D which has the immutable type qualifier which denotes a value that cannot change, not only through this reference but anywhere in the code base, becoming something closer to a true constant value. You also these days have constexpr and custom literals constructs muddying even more of the water.

People may have different opinions on how well this mechanism in languages work, it's only syntactic sugar, no memory is truly immutable if you really want to change it. C or C++ let's you lose the const qualifier if you feel like it at a whim, it only being undefined behavior if the qualifier is being removed from a constant object generated by the compiler like literal strings. If you are even more determined, just taking the address of the memory in question as a void pointer and then casting to whatever you want it to be usually does the trick.

Still despite all this I am pretty fond of it, I think it communicates intent and I find it makes writing code suitable for threading or multiplayer a lot easier. In essence making a contract, a promise, that you won't mutate the game state. That's how it's been used in my experience where I've worked, especially at games I've worked on that have had immense game states they needed to keep in sync over the network when playing multiplayer which is only achievable by a very brittle lockstep protocol. the simplest but most powerful tool to make sure the game state remains in sync? Be very clear with what operations change the game state.

For instance, the player clicking around in menus and hovering over tooltips shouldn't mutate the game state, but let's say the interface calls a calculation function that is keeping some mutable cache which it updates, that cache is later on used by one of the many tick or update functions in the game state and voila, host and client are now starting to diverge and starting to play two very different games.

So whenever I encounter a language without const type qualifiers I always feel like something is missing. As if a very beloved screwdriver from my toolset has been yoinked from me. I feel this very clearly when working in Godot using GDScript which has a very python-esque syntax. I do some code in C++ through the awesome GDExtension framework but I do want to use GDScript as it works great for quickly prototyping up features and all their features like the debugger, profiler and more just works so great in the editor with no effort on my behalf.

So I decided to just fake it.

The concept is based around wrapping the mutable object with a restricted adapter called "view". It holds a single reference to the object and exposes only methods that are deemed "const" through wrapper methods and lives only as long as we need that kind of access to the game state object. There's some complications because most things in GDScript is passed by reference, like arrays or objects we hold reference to and we'd be violating our contract of const if we simply returned them, so how do we handle that? Well the wrapper method will have to construct that objects or arrays version of a view. This obviously has some performance implications, but one of the founders, reduz, did tweet ages ago that allocating a lot of small objects should be fairly fast in Godot because of how they manage memory, so this method would be banking on that pretty heavily. I'd link the source but can't find it as reduz has made his old tweets protected, understandably.

As they say you should always profile things, but these views in my use case will be mainly used for the interface so I don't have the same performance requirement as someone else might have. But I did some very simplistic testing which you should take with a huge grain of salt, but works for my purposes here, especially as I have a limitation of that I could only measure down to microseconds for Godot which is not enough precision in this case. Doing an allocation + free in Godot through GDScript took ~1.1μs while doing it in bare C++ (so not Godot's memory allocators in C++) took 0.105μs. I did discover that not doing a free in Godot would cause the next allocation to be consistently slower, doing about 1.7μs per allocation, which does hint to me that it's reusing the objects in a way that works great for what we'll be doing. Either way it is an order of 10 times slower than just doing a normal allocation, which is not great but fine I guess. Something to test in the future is what time an allocation through C++ inside a GDExtension using their allocators takes to figure out what is the script overhead specifically, but in my use case I'll be making these classes in script so it doesn't help me right now.

The code used for the performance test, please be gentle.

#include <chrono>
#include <iostream>
#include <memory>

using namespace std::literals::chrono_literals;

struct SimpleObject {
  std::string temp1;
  int32_t temp2;
};

void allocator() { auto obj = std::make_unique<SimpleObject>(); }

int main() {
  typedef std::chrono::high_resolution_clock Time;

  int32_t iterations = 0;
  auto accumulation = 0ns;
  for (int test = 0; test < 100000; test++) {
    for (int i = 0; i < 32; i++) {
      auto start = Time::now();
      allocator();
      auto end = Time::now();
      auto diff = end - start;
      accumulation += diff;
      iterations++;
    }
  }
  std::cout << std::fixed << accumulation.count() << "ns" << std::endl;
  std::cout << (accumulation.count() / iterations) << "ns/allocation"
            << std::endl;
}
extends Node

class TestObject extends Object:
    var temp1:String
    var temp2:int

func _ready() -> void:  
    var iterations:int = 0
    var accumulation:int = 0
    for test in 100_000:
        for i in 32:
            var start:int = Time.get_ticks_usec()
            var obj := TestObject.new()
            obj.free()
            var end:int = Time.get_ticks_usec()
            var diff:int = end - start
            accumulation += diff
            iterations += 1
    print(accumulation, "us")
    print((accumulation/float(iterations)), "us/allocation")

So this tells us that constantly allocating objects does have a hefty overhead, but small objects with not so long lifetime is not necessarily a huge problem for us, especially since these will be created mainly when the player is interacting with the interface.

Now let's actually start with using this with our game state. I start with the game state as a node in the scene tree, so when the scene tree is reset the game state is also reset. On the class we attach to that node we have some static methods to easily access the node making it sort of a singleton, we do not however keep any static references to the game state. I prefer to do it this way as with Godot we don't have easy support for destructors so this will cause things to be cleaned up properly when we switch out the node in the tree. To make that global access work we make the game state be part of a group we can access instead. Inside of the access functions we check if we are in the correct mutable state before returning the node. This is of course slower than a reference but I think it's more "Godot"-style if that makes sense.

class_name GameState extends Node

enum AccessState {
    MUTABLE,
    IMMUTABLE
}

static func access_logic() -> GameState:
    if __access_state != AccessState.MUTABLE:
        Log.error("Tried to gain mutable access game state when not allowed!")
        return null
    return _access()

static func access_interface() -> GameStateConstView:
    return GameStateConstView.new(_access())

static func _access() -> GameState:
    var tree:SceneTree = Engine.get_main_loop()
    return tree.get_first_node_in_group(&"game_state")

func _ready() -> void:
    add_to_group(&"game_state", true)

static var __access_state:AccessState = AccessState.IMMUTABLE

The static method access_logic() is intended for when the game state is supposed to be mutable, like when we are running game logic and updating stuff. If we are in the correct state then we just return the node. In the next function named access_interface() we wrap a reference to the game state in a class called GameStateConstView which only exposes methods that are "const-correct". For instance if our game state would have a list of entities that you could access, in GameStateConstView we would provide methods to safely access those entities, themselves being wrapped in an EntityConstView class itself providing a const interface to the underlying entity.

It is quite a bit of boiler plate to manage since GDScript is not particularly feature rich when it comes to metaprogramming, but I feel that's a blessing in disguise as it stops me from making my life way harder than it needs to be and keeps this code very simple and easy to follow. As an example to find an entity by an id could be written as:

func find_entity_by_id(id:int) -> EntityConstView:
    var search := func(e:Entity) -> bool: return e.id == id
    return EntityConstView.new(_obj.entities.find_custom(search))

This is assuming that GameState does not provide it's own find_entity_by_id which would make this example even more trivial.

After having been using this for a while, it doesn't feel like too annoying work to keep a ConstView version of a class up to date to the underlying type it is wrapping. Occasionally I've forgotten to update but Godot usually tells me quite quickly that I've fucked up. Would I love if this was a bit automatic, perhaps even built in to the language? Yes absolutely. But for now I feel it does it's job without too many cons to be using it.