I've wanted a real scripting language inside ABS Engine for a while. This week I finally sat down and did it: the engine now embeds a Squirrel VM, and the first function has already moved into it.
Why Squirrel
ABS Engine is a 2D engine written in Python on top of pygame, with a Tkinter editor. It already had a second language in it, the editor's theming lives in engine/tcl/theme.tcl, loaded by a small tcl_loader.py. That worked well, so the shape was already familiar to me.
Squirrel is a small, embeddable language built for games. Braces, classes, closures, generators. It's what Left 4 Dead 2 and Portal 2 use for their scripting, which is a decent endorsement. And there's a Python binding on PyPI called squirrel-lang that wraps Squirrel 3.2 stable, so I didn't have to write any C.
I'm also a contributor to that binding, which is part of why I picked it. Seven of my PRs are merged upstream, including the type stubs it now ships for the VM API, a fix for the CMake pybind11 path lookup, clearer errors on a failed build, and a handful of lint and README cleanups. Having already been inside the project made embedding it a lot less daunting.
uv add squirrel-lang # well, almost. See below
The loader
I built engine/nut_loader.py to mirror the Tcl loader I already had. It's about 85 lines:
NUT_DIR: Final[Path] = Path(__file__).parent / "nut"
@cache
def get_vm() -> squirrel.StaticVM:
return squirrel.SQVM()
def nut_source(script_name: str) -> Any: ... # run a .nut file
def nut_eval(nut: str) -> Any: ... # run a string
def nut_call_function(name: str, *args) -> Any: # call into the VM
The VM is a lazy singleton. functools.cache on a zero-argument function gives you a thread-safe, created-on-first-use singleton for free, and it means importing the engine doesn't spin up a VM unless something actually runs a script.
Calling into it is nicer than I expected. vm.get_roottable()["add"](3, 4) returns 7. Real Python values in, real Python values out, no string interpolation and no escaping. Ints, floats, strings, lists and dicts all cross cleanly. Python exceptions raised inside bound functions propagate back with their original type.
The first function
I started small on purpose. engine/nut/math.nut:
function clamp(value, low, high) {
if (value < low) {
return low
}
if (value > high) {
return high
}
return value
}
That's now clamping color channels in two places in the engine core: Entity.__init__ and Game.set_bg_color. It fixed a real crash, too: pygame raises ValueError: invalid color if any channel falls outside 0-255, and the game loop fills the screen every frame, so a computed color that overshot would take the whole game down mid-frame. Now it just clamps.
What the benchmarks told me
I went in assuming I'd move hot loops into Squirrel for speed. I measured it instead, and I'm glad I did. Identical collision broadphase, 400 rects, ~80k overlap tests:
| implementation | ms/frame |
|---|---|
| Python | 2.01 |
| Squirrel, native in-VM arrays | 3.44 |
| Squirrel, reading Python lists | 53.28 |
So it's slower: about 1.7× at best, and 26× worse the moment it indexes a Python list, because every element access crosses the boundary. That last number is the important one: a Squirrel module has to own its state inside the VM and cross rarely, or you pay for it.
That killed my "rewrite the hot paths" idea, but it clarified what embedding is actually for here: isolation and hot reload. Re-running a .nut file into a fresh environment table is one line. You can't meaningfully reload a Python module mid-frame.
Two gotchas
First, uv add squirrel-lang failed outright. My pyproject.toml declares dependencies as dynamic (sourced from requirements.txt), and uv add writes a static [project].dependencies. PEP 621 forbids having both. Nothing to do with Squirrel, but it cost me twenty minutes. If your project is set up that way, edit the requirements file and run uv sync instead.
Second, mypy refused to read the binding's type stubs. squirrel-lang ships an __init__.pyi, but without a py.typed marker PEP 561 says a type checker has to ignore it, so I needed an ignore_missing_imports override in pyproject.toml. I have a PR open upstream to add the marker, which should make that override unnecessary.
What's next
This is deliberately a small first step. The things I want to try:
- Procedural generation: runs once at load, so the performance gap costs nothing, and it's the most fun code to write in a scripting language.
- Easing and tween curves: tiny pure functions, and exactly the kind of thing you tune by feel and want to reload without restarting.
- Entity behaviors: patrol, follow, oscillate, gravity, all shipped with the engine and picked from a dropdown in the editor.
I'm also thinking of adding other interpreted languages, like Lua. The loader has turned out to be the reusable part of this: tcl_loader.py and nut_loader.py are nearly the same file, just a directory constant, a guard that the directory exists, and a few functions to source a script, evaluate a string, and call back into the VM. Adding a third would mostly be swapping the backend underneath that shape. Lua is the obvious candidate, since it's the language most people writing game scripts already know, and LuaJIT would actually deliver the speed Squirrel didn't. I don't want to collect languages for the sake of it though, so each one has to earn its place. The point is that the engine is now built so trying one is an afternoon instead of a rewrite.
More Squirrel is coming. ABS Engine is open source under the GPLv3 and lives on GitHub, so if any of this sounds useful you can clone it and poke at the .nut files yourself. Issues and PRs are welcome, and so is a star if you want to follow where this goes.
I'd genuinely like to hear from people who have embedded a scripting language in their own engine. Did you end up exposing a wide API to scripts or keeping it deliberately narrow? Did hot reload turn out to be as useful in practice as it sounds in theory? Those are the two decisions I expect to get wrong first, so tell me what you learned in the comments.
Top comments (0)