Godot Sandbox: The Answer is 42
SafeGDScript now runs the full 42 official Godot demo projects for five seconds each. Yay ™️.
SafeGDScript runs real projects
SafeGDScript can now run in place of GDScript in all 42 projects from the official Godot demo repository, running each one headlessly for five simulated seconds. On top of that, the language now has support for structs, nullable types, union types and, in the current work, traits. I modelled the traits feature on the PR in godot-proposals.
The demo run is not a conformance test, but these projects were written as ordinary GDScript. It took me a few days to work through all the issues. Afterwards I ran a few projects manually, which also revealed more issues, now fixed. And yeah, was kinda cool being able to run the 3D truck selector project with my own language implementation.
Note that this is not sandboxed with restrictions. It's only possible to run unmodified GDScript as SafeGDScript with restrictions disabled, which gives full access to Godot. Shouldn't be a surprise.
SafeGDScript is fast with JIT
I have a benchmark suite in-repo that keeps me honest:
| benchmark | unit | GDScript | JIT | no JIT | JIT vs GDScript | no JIT vs GDScript |
|-------------------------|----------------|---------:|-----:|-------:|----------------:|-------------------:|
| array append + index | element | 82.9 | 71.1 | 126 | 1.17x | 0.68x |
| call overhead | call | 122 | 91.0 | 167 | 1.35x | 0.72x |
| container size | size call pair | 48.4 | 31.4 | 71.9 | 1.54x | 0.68x |
| dictionary set + get | op | 42.9 | 39.8 | 63.0 | 1.08x | 0.65x |
| float loop | iteration | 37.8 | 6.76 | 59.2 | 5.59x | 0.65x |
| int loop | iteration | 38.3 | 6.30 | 41.2 | 6.08x | 1.01x |
| logic CPU dispatch | emulated instr | 749 | 142 | 292 | 5.26x | 2.64x |
| recursion | call | 94.6 | 13.1 | 35.3 | 7.22x | 2.75x |
| single-instruction step | emulated instr | 835 | 243 | 516 | 3.43x | 1.63x |
| string build | string | 121 | 148 | 205 | 0.82x | 0.58x |
| string iterate | character | 37.4 | 67.6 | 111 | 0.55x | 0.33x |
| struct construction | instance | 208 | 277 | 358 | 0.75x | 0.54x |
| struct field read | iteration | 99.7 | 127 | 177 | 0.79x | 0.55x |
| untyped float compare | comparison | 40.4 | 18.4 | 72.2 | 2.20x | 0.56x |
| untyped float math | operation | 32.6 | 16.9 | 65.2 | 1.93x | 0.48x |
I've held off on this kind of parity work for a long time, both because I don't write GDScript well, and because I focused on the sandbox. However, it occurred to me that a lot of people simply want a faster GDScript, and I think that's possible.
It's mostly faster than GDScript with the JIT, from 17% faster to 700% faster on my laptop, but it's still lacking in some areas. This "lack" is mostly compiler-side and some of it is in the sandbox boundary (system call layer) where I'm doing silly things with Variants that end up costing. The JIT is not available in Web export, but you can bake a safe binary translation into the final project for releases (which also works on Switch, iOS, etc.)
Structs with methods
Structs are Dictionary-backed fixed member things with support for constructors. Hence, they can be used transferred to and from GDScript without any special support. "It's just a Dictionary" is the intended design, even if some don't like that direction. I think it makes sense to have full interop with GDScript.
struct Item:
var name: String
var value: int
var dropped_at: Vector2?
func try_stack(other: Item) -> bool:
if self.name != other.name:
return false
self.value += other.value
return true
func drop(at: int | Vector2) -> void:
if at is int:
self.dropped_at = Vector2(at, 0)
else:
self.dropped_at = at
var inventory: Array[Item] = []
func add_item(item_name: String, item_value: int) -> bool:
var item := Item(item_name, item_value)
for stored in inventory:
if stored.try_stack(item):
return true
inventory.append(item)
return false
func _ready():
add_item("coin", 1)
add_item("gem", 5)
add_item("coin", 1)
inventory[0].drop(Vector2(64, 32))
So, Item(name, val) constructs with the remaining members getting assigned defaults.
Nullable and union types
A type can now say what the program was already doing. T? is the short form of T | null, and unions such as int | Vector2 are checked while compiling when possible and guarded at run time when the value is unknown. Null checks, is checks, assert and a few common branches narrow the type again, so the compiler can return to its faster typed paths.
func drop(at: int | Vector2) -> void:
if at is int:
dropped_at = Vector2(at, 0)
else:
dropped_at = at
Godot's ABI still sees a multi-type union as a Variant, because there is no union type on that boundary. That is alright. The useful part is that the compiler and the person reading the script both know what is allowed.
Traits
Traits are the newest addition in the current work. A trait can add state, constants, enums, signals and concrete or static methods, while abstract methods state what the using class must provide. Traits can use other traits, and a script or nested class opts in with uses.
uses Damageable
trait Damageable:
var health: int = 100
func take_damage(amount: int) -> void:
health -= amount
@abstract func on_death() -> void
func on_death() -> void:
queue_free()
SafeGDScript classes satisfy traits nominally, so value is Damageable works when the class declares it. Foreign Godot objects can satisfy the same trait structurally by providing the required instance methods. This keeps the type useful on both sides of the sandbox without pretending the engine grew a new inheritance system overnight. Presumably Godot will eventually have traits too, as this work is based on a PR that seems to have some modicum of traction, and then we will have to revisit this.
Final thoughts
My Godot Sandbox extension is being pulled in two directions at once now: broader compatibility with ordinary Godot code for unrestricted sandboxes, while maintaining the same hardened layer that makes restricted sandboxes safe.
I've mostly reached parity with GDScript now, however there is probably small differences left and heaps of bugs. A lot of work remains to make in-editor SGD work like people perhaps expect. I've also added a SafeGDScript syntax extension for VSCode.
Thanks for reading. Bye.
-gonzo
