Scripting Module Structure
Prerequisites
- ECS and the Game World
Article
We now have enough of our engine built that we can implement a useful scripting language and interface and do something interesting. That said, implementing a scripting language is a lot of work, so let's talk about why you we want scripting capabilities:
- It's easier to use. C++ is performant, highly available, and also really hard to do well. Languages like Javascript, C#, Python, and Lua all allow the game designer and gameplay programmers to focus on writing game logic.
- Separation of game logic - you can ship a compiled game engine library and editor and everyone can build games (with scripts) on top of it. Said another way: the scripts make the game. This also results in shorter compile times as most scripting languages are compiled just-in-time (JIT, eg. at run-time).
- Added functionality - many other languages support reflection, dynamic types, garbage collection, class and function attributes, and more. You and your gameplay programmers can take advantage of these "for free" by implementing a language that supports them.
I'm sure there are other reasons. Of course, the biggest question that always comes up is performance. In reality, most games today don't need to squeeze every ounce of performance out of the system, except maybe in the rendering engine, which we'll write in C++. However, where necessary, we'll leave the door open to writing extensions or scripts in C++.
Now that we all agree that implementing a scripting language is a good idea, let's talk about which language we're going to implement.
C# and Other Scripting Languages
In this series, I'm going to be implementing C# on top of Mono. In the past I've also implemented Javascript and Lua, and I keep coming back to C#. Both Javascript and Lua are functional programming languages - while you can implement object-oriented programming, it is not native. I have found interfacing and extending the engine more natural by using C#'s inheritance - our scripts can derive from system or component classes to create and extend our engine's capabilities.
I will say that Lua is very easy to integrate. As for Javascript... Javascript was the first language I used for scripting. I have only tried integrating with Google's V8 library, which I can say is extremely painful. V8 was built for Chromium and even compiling the runtime takes some arcane skills. As an example, at least as of the time of writing this, the last step to get a .lib file on Windows from a V8 build was to manually combine all of the outputted object files using a command line tool. There are at least a dozen more "bang your head on the wall until you find the right combination of keywords to Google for" moments. Once you have a compiled library, actually integrating with V8 is not bad. Many of the principles in this series would easily extend to using V8 and Javascript.
I have not personally experimented with embedding Python, though I think it would be interesting. I did a cursory evaluation and didn't see a way to make C++ code callable from Python. Since embedding Python is generally the same as writing an extension, I'm sure it must be doable, but it was not readily apparent.
Finally, several other engine use C# - Unity and Godot, for example. In this series, we're going to implement many of the same concepts you see in Unity, so you'll have a good understanding of how they work and whether you would want to implement that into your own engine.
Let's take a look at some of the key concepts for implementing a scripting language, and then we'll dive into the C# and Mono integrations for each.
Concepts and Organization
While our integration will be both deep and bi-directional (calling C++ functions from C# and vice versa), we can simplify our scripting system down into four basic concepts and classes:
- Script - a script is a class that can be "run". That is, it implements an expected set of functions and can be attached to many objects in our game world that are executing the same code. Our game play programmers could have an Ogre script and a Player script, or they can break it down into smaller pieces that process input, allow customizations, define movement, etc. Importantly for our design, an entity or game object may have more than one script attached and each instance of a script will need to manage its own state (two objects with the same script attached may have different states).
- ScriptComponent - Following our Entity-Component-System design, Scripts will get attached to Entities via ScriptComponents. Each ScriptComponent will have a Script it is executing, and each ScriptComponent will maintain the state mentioned above that is specific to a Script-Entity instance.
- ScriptObject - A ScriptObject is any object that exists in both our engine and the scripting environment. This class acts as an interface or relationship between what we'll call a "local" object that exists in C++, and a "remote" object that exists in C#. For example, a user may want to move the camera by calling something like
gameObject.GetComponent<CameraComponent>().transform.Move(...)
from C#. Calling this code would necessitate creating and returning 3 objects into our C# environment (an Entity, a CameraComponent, and a Transform) that already exist in our C++ environment - the ScriptObject will maintain that relationship. - ScriptSystem - Finally, our ScriptSystem will bind all of this together. This system will manage ScriptComponents, load libraries, manage Scripts and ScriptObjects, and be responsible for interfacing between C++ and C# types.
Each of these classes will be derived from to create Mono specific variations. Later, if we wanted to implement a different scripting language, you could simply swap out what type of Script, ScriptComponent, and ScriptSystem you're using.
Let's take a look at the Script class:
class UScript : public UResource {
public:
UScript() = default;
virtual ~UScript() = default;
public:
// Class name
std::string className;
// Variables in class (and type)
std::map<std::string, uint32_t> vars;
};
Nothing too fancy here. As we discussed above, a script is really a class that we'll be calling into for a predefined set of functions (which we'll see in ScriptComponent). We store the class name so that we can serialize and deserialize what type of class is attached later on, and we'll store the list of serialized and public variables and their types. Later, we'll display all of this information in the editor and give the user different controls based on type (a textbox for string, X/Y/Z for a 3D vector, etc.). State of those variables will be stored in ScriptComponents, which will bind a Script type to an in-game object or Entity:
class UScriptComponent : public UComponent {
public:
UScriptComponent() : m_script(0) { };
virtual ~UScriptComponent() = default;
/**
* Overridable functions
*/
virtual void Initialize(UScript* script) { }
virtual void Update(float delta) { }
virtual void FixedUpdate(float delta) { }
virtual void Destroy() { }
protected:
// Script
UScript* m_script;
// Variable settings
std::map<std::string, UVariant> m_vars;
};
Our ScriptComponents have 4 primary functions that may or may not be implemented by each Script:
- Initialize - sets the Script and calls the Initialize() function in the Script if there is one.
- Update - called once per frame, passing in the delta time between frames
- FixedUpdate - called on a fixed timestep (eg. 30ms), passing in the fixed timestep delta
- Destroy - called on the destruction of the ScriptComponent
These functions will look familiar if you've worked with Unity's scripting system before. These functions give us the flexibility to implement components and systems in C#, if we want to stick to our ECS design, or allow gameplay programmers to group logic together into more monolithic pieces (as we mentioned earlier, maybe a Player class and an Ogre class that contains input, collision detection and response, event handling, etc.). As always, there's no right answer here - only the best answer for the type and complexity of the game you're creating.
Next, we have our ScriptObject:
class UScriptObject : public UObject {
public:
UScriptObject() : localObject(0) { }
virtual ~UScriptObject() = default;
public:
// Our local object
UObject* localObject;
};
Now, the astute reader will realize that earlier I said that this will contain a relationship between a local C++ object and a remote C# object and yet we only have a reference to the local object here. That is because the remoteObject's type is actually implementation dependent. In Mono, it will be a MonoObject, but in V8 it would be a v8::Object. You could store this as a void pointer and put them both in one place, but you end up doing A LOT of casting, which is generally a bad idea. We'll see the remoteObject property show up when we look at our Mono-specific implementation of ScriptObject.
Finally, here is our ScriptSystem:
class UScriptSystem : public USystem {
public:
UScriptSystem() = default;
virtual ~UScriptSystem() = default;
/**
* Start system
*/
virtual void Start() { }
/**
* Update loop
*/
void Update(float delta);
/**
* Load library
*/
virtual void LoadScriptLibrary(std::string path) { }
/**
* Find a script class
*/
virtual UScript* GetScript(std::string className) { return(0); }
/**
* Get or create an object relationship from Mono to local
*/
virtual UScriptObject* GetRemoteObject(std::string className, UObject* localObject) { return(0); }
/**
* Gets a local object, given a MonoObject
*/
virtual UScriptObject* GetLocalObject(void* remoteObject) { return(0); }
protected:
// Fixed time step counter
float m_fixedUpdateCounter;
};
Starting from the top, we have the Start() virtual function. This is an entry point that can be called after initialization to load any engine classes or libraries ahead of game-specific libraries.
Next up is our Update function - this will be called once per frame and will iterate over all of the ScriptComponents in the world and run their Update functions:
void UScriptSystem::Update(float delta) {
std::vector<UScriptComponent*> scripts = UWorld::Instance()->GetComponents<UScriptComponent>();
auto it = scripts.begin();
for (; it != scripts.end(); it++) {
(*it)->Update(delta);
}
m_fixedUpdateCounter += delta;
float fixedUpdateStep = 1.0f / UENGINE_TICKS_PER_SECOND;
while (m_fixedUpdateCounter > fixedUpdateStep) {
it = scripts.begin();
for (; it != scripts.end(); it++) {
(*it)->FixedUpdate(fixedUpdateStep);
}
m_fixedUpdateCounter -= fixedUpdateStep;
}
}
This function also tracks the fixed timestep and calls the ScriptComponents' FixedUpdate function accordingly. For more information on a fixed timestep, check out the article on the UTimeSystem.
Next up, our ScriptSystem has a virtual LoadScriptLibrary function, to be implemented by however we load scripts. Windows note: I really wanted to call this function LoadLibrary, but like CreateWindow, it doesn't matter if it's inside of a class, you can't call it that. Argh.
Since our ScriptSystem will be the arbiter of Scripts, we have a GetScript function to find a Script type by class name. We'll use this function when we serialize or deserialize a ScriptComponent, since we'll only know that it has a "Player" script attached to it.
Finally, we have our GetLocalObject and GetRemoteObject functions, both of which return a UScriptObject relationship object. This will help us find a local C++ object, given a MonoObject or v8::Object, or help us find a MonoObject (or v8::Object) given a local C++ object.
Next Steps
With a solid set of foundational classes, we can now start implementing our Mono-specific versions, creating a very tight integration between our engine's C++ code and our C# game logic while still maintaining a clean separation between engine and gameplay code and getting all of the advantages a language like C# has to offer.
In the next article, we'll look at embedding Mono generally and then we'll move on to the nitty gritty of our implementation.