Introduction
Scripting has grown to be an essential part of modern game engines. When I've been given an opportunity to choose my own research topic as part of my university project, I chose to go with a scripting implementation into a custom C++ game engine.
Why scripting?
Since I started working with custom engines about a year ago, I realized how many things I took for granted in big game engines such as Unity or Unreal Engine. One of the biggest hindrances turned out to be writing C++ for gameplay code.
The issue I had with C++ for gameplay was mainly the lack of fundamental features, such as reflection and hot-reloading. Runtime reflection in C++ is somewhat achievable, but hot-reloading is impossible to get working properly. If we take a look at Unreal Engine, they have one of the most advanced C++ hot-reloading systems out there, but it still requires engine restarts often.
That's why I had to steer away from C++ and search for scripting languages that could be implemented into my custom engine. I considered quite a few: C#, Wren, Luau, but one turned out to be the perfect fit: AngelScript.
Why AngelScript?
The reason I chose AngelScript over other options is because it is statically-typed and cross-platform. Additionally, programmers coming from C++ can immediately understand and write AngelScript code, because of its similarities.
I didn't go with Wren or Luau because there is a steep learning curve for programmers coming from C++. The syntax is very different, and Luau is not an OOP language by design. Both Wren and Luau are also dynamically typed (though they support type annotations).
The issue I found with C# is that it is very difficult to implement. It isn't a scripting language by design and while there are ways to make it communicate with C++, getting this to work on the consoles would be nearly impossible for my timeframe of eight weeks.
What is AngelScript?
AngelScript has been around since 2003 and to one's eye, it looks almost identical to C++. However, it is a VM-based scripting language with easy host integration. AngelScript introduces new concepts intended to simplify complex features such as pointers. The language is garbage collected and has an extensive API for native interaction.
It has been picking up steam over the last years with Hazelight being the pioneers of UE-AS and Embark Studios using it for THE FINALS and ARC Raiders.
Scripting architecture in game engines
When it comes to writing scripts in game engines, we first have to think about the approach we want to take. Generally speaking, we can write scripts that are either attached to entities or iterate over many entities and perform a common logic. The first approach is something that Unity does with their MonoBehaviours, while the latter approach is the true ECS system solution.
The OOP approach is arguably the way most people are used to writing scripts in modern game engines. You write scripts with a simple life-cycle functions and attach these scripts to entities. These scripts can then change the entity's position, interact with other entities or call engine API.
However, writing scripts using this approach ignores the contiguous memory of an ECS, which theoretically takes a hit on performance. On the other hand, we are already writing code in a scripting language, which sort of implies degraded performance compared to native C++ code.
When it comes to scripting using ECS systems, I found it to be a mixed bag. For certain features, it makes a lot of sense to write code that iterates over a view of many entities and performs some logic. In my opinion, it makes sense for certain simulation games, but writing a player controller in an ECS system is very strange. Additionally, ECS systems complicate cross-system communication and provide no straightforward mechanism for serializing system state.
All things considered, I decided to take the OOP approach, since it's what I found most convenient.
Implementation
I will now walk you through the basics of implementing AngelScript scripting support into a custom engine.
Disclaimer: The implementation I am about to show was made for an ECS-based game engine that I developed in eight weeks as an assignment over at BUas. The architecture is therefore specific to the engine choices I made, but will still showcase general AngelScript concepts.
Building AngelScript
We first need to build AngelScript and link it against our project. I recommend reading the Compiling the library chapter in the AngelScript docs. This step really depends on the way your project is set up, but I went with pre-compiling AngelScript into a static library and then linking it. I personally didn't need to recompile the library once throughout the development, but making AngelScript compilation be a step of your build process would allow for more flexibility when changing something in the library.
Initializing AngelScript
With AngelScript being linked, we can now start using it. The whole library has a single header file that we care about: angelscript.h. I recommend adding this to your include directory for convenience.
When it comes to initialization, we need to create two things: asIScriptEngine and asIScriptContext. The ScriptEngine is the backbone of AngelScript, as it handles everything from registering types, managing modules, to compiling scripts. The ScriptContext is used for preparing function calls, setting arguments, retrieving return values, managing the call stack. We will see both of them in action a bit later.
There is one thing to note. We usually need a single script engine, as AngelScript also introduces the concept of modules, that take care of splitting different parts of code into their own sections. Different scripts can be compiled into different modules, which then make them completely standalone units. Cross-module interaction is possible using the shared keyword, but I did not personally try that.
If we had the option to create engine debug windows using AngelScript, we don't really need the game code to know about it. That's a situation where splitting into multiples modules would pay off.
The initialization code looks like this:
// main.cpp
#include <angelscript.h>
int main()
{
asIScriptEngine* engine = asCreateScriptEngine();
asIScriptContext* context = engine->CreateContext();
}Out of the box, AngelScript also offers a bunch of add-ons. These add-ons offer features as: an implementation of std::string, a generic script loader or a templated implementation of an array. We can register these using the following code:
// main.cpp
#include "../add_on/scriptstdstring/scriptstdstring.h"
#include "../add_on/scriptarray/scriptarray.h"
RegisterStdString(engine);
RegisterScriptArray(engine, true);Before we do anything else, we need to register the MessageCallback, which will give us quite an insight into what the engine is doing.
// main.cpp
void MessageCallback(const asSMessageInfo* msg)
{
const char* typeStr = msg->type == asMSGTYPE_ERROR ? "ERROR" : msg->type == asMSGTYPE_WARNING ? "WARNING" : "INFO";
if (msg->type == asMSGTYPE_ERROR)
{
if (msg->section)
{
printf("[%s] %s:%d:%d: %s\n", typeStr, msg->section, msg->row, msg->col, msg->message);
}
else
{
printf("[%s] %d:%d: %s\n", typeStr, msg->row, msg->col, msg->message);
}
}
else if (msg->type == asMSGTYPE_WARNING)
{
if (msg->section)
{
printf("[%s] %s:%d:%d: %s\n", typeStr, msg->section, msg->row, msg->col, msg->message);
}
else
{
printf("[%s] %d:%d: %s\n", typeStr, msg->row, msg->col, msg->message);
}
}
}
engine->SetMessageCallback(asFUNCTION(MessageCallback), nullptr, asCALL_CDECL);The MessageCallback is quite big, but it gives us everything that we need. We can immediately see what and where went wrong.
If we run it now, AngelScript gets initialized, but there's nothing to see. So, let's load our first script.
Loading scripts
For loading scripts, I strongly recommend using the CScriptBuilder add-on. It comes with a lot of functionality that even supports preprocessing and metadata.
// main.cpp
#include "../add_on/scriptbuilder/scriptbuilder.h"
CScriptBuilder builder;
builder.StartNewModule(engine, "Game");
builder.AddSectionFromFile("../assets/scripts/hello.as");
builder.BuildModule();The usage is fairly straight-forward. We start a new module called Game and then load all scripts for that module. Once we are done, we call the BuildModule function. However, there is one thing to note. All of these functions return an integer return code and it is heavily advised to check this. For my usage, I created the following macro to VERIFY the results.
// main.cpp
#define VERIFY(call, msg) \
{ \
int r = (call); \
if (r < 0) \
{ \
printf("%s: %d", msg, r); \
} \
}If we apply this to our script loading code, we will assure that we get clear errors if some occur.
// main.cpp
CScriptBuilder builder;
VERIFY(builder.StartNewModule(engine, "Game"), "Failed to start module.");
VERIFY(builder.AddSectionFromFile("../assets/scripts/hello.as"), "Failed to add script file.");
VERIFY(builder.BuildModule(), "Failed to build module.");If you supply a correct path to the file, the program should run. However, we still get no input, so let's implement a print function.
Registering a function
A fundamental concept in implementing a scripting language is being able to call C++ code. This is usually referred to as internal calls. AngelScript provides a clear API for binding script functions like print() to arbitrary C++ implementations.
As mentioned earlier, the scripting engine takes care of registering types and functions, so we will use the asIScriptEngine::RegisterGlobalFunction method. First, let's take a look at what we want to achieve:
// hello.as
void main() {
print("Hello from AngelScript!");
}If we put this in the source AngelScript file and run our code, we should see this in our console:
[ERROR] path/to/hello.as:4:5: No matching symbol 'print'
Let's use the aforementioned RegisterGlobalFunction method to register the script function.
// main.cpp
void print(const std::string& str)
{
printf("script: %s\n", str.c_str());
}
void main()
{
// [ ... ]
VERIFY(engine->RegisterGlobalFunction("void print(const string&in str)",
asFUNCTION(print), asCALL_CDECL),
"print function registration failed");
// Script loading
// [ ... ]
}Let's break down the RegisterGlobalFunction call:
- I provide the function signature in a string. I can use the type
stringtype thanks to the string add-on that we registered earlier. - I specify the parameter as a const reference. However, AngelScript also requires you to specify whether it's an in-reference or an out-reference.
- I point to our C++ function using the
asFUNCTIONmacro. There are other macros such asasMETHODtoo. - I specify the calling convention. Calling conventions define how the scripting engine handles arguments and return values. Read more about them here.
Alternatively, we can also register a function using a lambda:
// main.cpp
VERIFY(engine->RegisterGlobalFunction("void print(const string&in str)",
asFUNCTION(+[](const std::string& str)
{
printf("script: %s\n", str.c_str());
}), asCALL_CDECL), "print function registration failed");The + forces the lambda to be in a captureless state, which makes it possible to convert it to a C function pointer.
If we run the code now, we should not have any errors about a function missing. However, we still don't see any output. We have the print statement in a main() function, which we are never calling.
Calling script functions
The last step to see the hello message is to actually call the main() function.
As mentioned earlier, the asIScriptContext is what allows us to interact with script functions. However, we first need to find the function that we want to call.
Functions are found in modules, which means we need a reference to the module and then we can search for the function by its declaration.
// main.cpp
asIScriptModule* mod = engine->GetModule("Game");
asIScriptFunction* func = mod->GetFunctionByDecl("void main()");If either of these steps fail, they will return a nullptr. After finding the function, we can use the context to Prepare it and finally call Execute.
// main.cpp
context->Prepare(func);
context->Execute();If we put this code in and run the program, we should see some output in the console: script: Hello from AngelScript!. Congratulations, you just ran your first lines of AngelScript! 🎉
Arguments and return values
The main() function is a primitive example, but we can also have script functions that take in arguments.
// hello.as
void arg_func(float number) {
print(format("arg_func: {}", number));
}
void main() {
print("Hello from AngelScript!");
}For pushing arguments, we can use the SetArg### functions on the context object that allow us to set arguments. We can set DWORDs (32 bit), QWORDs (64 bit), floats, doubles, bytes and also complex objects. The first parameter of the SetArgFloat function indicates the target argument's position in the AngelScript function signature. We only have one argument, so we put in 0.
// main.cpp
asIScriptFunction* func2 = mod->GetFunctionByDecl("void arg_func(float)");
context->Prepare(func2);
context->SetArgFloat(0, 3.14159f);
context->Execute();After running this, we should see this in the console: script: arg_func: 3.141590. Also, notice the format function in AngelScript. This is a very handy function for formatting that works exactly like fmt::format().
Let's add a function that returns a value and see how we can read that back from C++.
// hello.as
void arg_func(float number) {
print(format("arg_func: {}", number));
}
float calculate(float input) {
return input * 100.0f;
}
void main() {
print("Hello from AngelScript!");
}We do the steps in the same order: find a function, Prepare it, push the argument and Execute it. After the execute finishes, we can read the return value using the context's GetReturn### functions.
// main.cpp
asIScriptFunction* func3 = mod->GetFunctionByDecl("float calculate(float)");
context->Prepare(func3);
context->SetArgFloat(0, 10.0f);
context->Execute();
float value = context->GetReturnFloat();
printf("%f\n", value);If we run this, we should see 1000.000000 in our console. That's all the fundamentals there are to calling AngelScript functions.
Reentrancy
Reentrancy can happen if a script calls a C++ function that again calls an AngelScript function. These can happen quite often with certain features.
With our setup, this is currently not possible. The issue is that we have a single context, so if we try to Prepare the context while it's executing something else, we will get the asCONTEXT_ACTIVE error. This was an issue I faced at some point too, so I just ended up creating a new context for every function call. The AngelScript docs discourage this, but I profiled it and it did not have that big of an impact for me.
Type marshalling
One more thing I want to mention is type marshalling, which is the process of transforming types when they cross between C++ and AngelScript. Luckily, AngelScript takes care of this for us, so we don't need to worry about much.
As long as we register a type, we can then accept it as a parameter or return it from an internal call. We already saw an example of this in the print function. We take std::string in the parameter and when we call print("Hello"); from AngelScript, it automatically marshalls the AngelScript representation to the C++ version. Let's try an example of an internal call returning a value.
// main.cpp
std::string GetProgramName()
{
return "AngelScript Tutorial";
}
void main() {
// [ ... ]
VERIFY(engine->RegisterGlobalFunction("string GetProgramName()",
asFUNCTION(GetProgramName), asCALL_CDECL),
"Failed to register a function."
);
}Then in our AngelScript script, let's call this function and print its result.
// hello.as
// [...]
void main() {
print(GetProgramName());
}If all things went well, we should see script: AngelScript Tutorial in our console. The same type marshalling applies for more complex types that we will register in the next post.
Conclusion
That's the last topic that I will touch upon in this post. Here is the full commented code that combines all the code snippets.
In case there are some issues or questions, feel free to contact me at: [email protected]
What's next?
This concludes the first part of the AngelScript series. Sadly, we did not get far in the actual game engine implementation, but we did cover AngelScript fundamentals that will be required for us to continue.
I plan to create a series out of this. Here is a rough plan of all the chapters that I want to write. These will definitely change and mix up a bit, but the general idea should stay the same. Unfortunately, I don't have a time estimate of when all of these will be done.
- Introduction
- Registering custom types
- Creating script instances, calling Start() and Update() functions, handling exceptions
- Getting components, cross-script interaction
- Reflection & serialization
- Hot-reloading
- Debugger
Resources
- AngelScript website
- AngelScript docs (a very rich documentation of the whole AngelScript SDK)
- AngelScript forums (a forum page, where the AngelScript creator actively answers questions)
- AngelScript LSP (a VSCode extension that makes the script writing experience very pleasant)
- HazeLight's AngelScript documentation (this is specific for Unreal Engine, but general AngelScript features still apply)