Object introspection (also called metadata or reflection) is a cool feature that gives users information about their objects and data types at run time. This means you can check which variables an object has, their values, and their types, and use this information in your favor to perform cool tricks. GameMaker has some of this functionality implemented already, but due to backward compatibility and decisions on how to handle their data types, a few of these functions don’t work as you’d expect them to. Maybe once they implement data structures as true types and not just handles, and treat boolean as its own type, we could come back to this project and improve how it works. However, this is the best solution I could find with what we have today.
It may not be obvious at first what you can do with this metadata from your objects, but here are a few examples of what is possible:
Serialization and deserialization
Debug draw information about an object to the GUI
Inspect and modify all the values from an object using a custom editor
A decent approach to all these problems would be to write a script or user event for every object you want to serialize/inspect and call that. The problem is that this solution isn’t scalable, and would get harder to maintain as the project grows. My solution is to have scripts for every data basic data type, alongside a database of objects with their variables and types. We can then use this information to create a generic function that achieves our purpose trivially since we can reuse the same functions for every object. It is also possible to do this recursively, so you can inspect the variables of an object if you have a reference to it inside of another one.
That last paragraph may have been a tad confusing, so let’s get started with the code and it should make more sense.
First, we’ll make an object that will hold the information about every object. I called it oOI_Manager. Activate the persistent flag on the object since we need it to be active all the time. Next, write this code in the create event:
// oOI_Manager Create event
// Ensures there's only one instance of this manager
Singleton();
// Enum that lists all valid data types (more can be added if needed)
enum OI_VarTypes
{
Int = 0,
Float,
Bool,
String,
Enum,
Object
}
// Create a global variable to easily access the manager
global.OI_Manager = id;
_objTypeMap = ds_map_create(); // Map of 2D arrays to store objects and variable definitions
_currObject = noone; // Keeps track of the object we are defining
_currIndex = 0; // Keeps track of the variable index for the current object
Breaking the code down, we first see we have a script called Singleton that ensures there’s only one copy of this object in the game. It’s a simple script that looks like this:
// Singleton Script
if (instance_number(object_index) > 1)
instance_destroy();
We then create an enumerator with all the data types we will be storing for now. Since this is part one of the article, I will cover the basic ones, and we will address data structures in the next post because it requires a bit more work. It is also useful to create a global variable and pass it the id of this object so we can access it easily, but it’s not necessary as you can use instance_find to get the first instance which should be the only one.
Last, we have the map where we will store the description about our objects, and two variables to keep track of which object we are describing and the variable index we are on. I will cover that later.
When I’m working on new features like this one, I usually start by writing how I want the interface to look like, and then I code the actual functions. Here, I decided this is what I was going for:
// Create event of the object you want to describe
if (OI_DefineBegin(object_index)
{
OI_Add("varName", OI_VarTypes.Int);
OI_Add("varName2", OI_VarTypes.String);
OI_DefineEnd();
}
It is pretty straightforward. You call a function to start the description (which returns false if the object has already been described, skipping the rest of the function calls). You then add all the variables you want to keep track of, and you end it.
Let’s write these three functions now, starting with OI_DefineBegin. Our goal with this function is to set all the initial values to our variables and to set the currObject variable to the one that called the function. This is what it all looks like:
// OI_DefineBegin
// Get the parameters
var object = argument0;
// Get a handle to the OI_Manager
var manager = global.OI_Manager;
// Show warning for misuse of the function
if (manager._currObject != noone)
{
show_debug_message("Warning: OI_DefineBegin was called, but a previous call wasn't closed using OI_DefineEnd.");
Return false;
}
// If the object already has a definition, skip it.
if (ds_map_exists(manager._objTypeMap, object))
Return false;
// Set initial values
manager._currObject = object;
manager._currIndex = 0;
// Set an empty 2D in the object's map slot
var arr2D;
arr2D[0, 0] = 0;
manager._objTypeMap[? object] = arr2D;
// We are ready to accept variables now
return true;
The first few lines are making a variable for the argument passed in, getting a reference to the manager, and checking if the function is being used correctly and there isn’t another object being described. Next, we check to see if the object has already been described, since multiple instances of the same object can call this function and we only need it to happen once, so we return false. We then set the current object to the one passed in, and set our current variable to zero. The next section may seem weird, but this is the only way I found worked to make each entry in the map be an empty 2D array, which is how we will be storing our variables and information. The data structure will look something like this:
This is a rough representation of the data structure we are using. Maps are usually represented by trees, but in order to keep the drawing simple, I made it look like an array or list.
Once we create the array and assign it to the map entry, we return true to specify that we are ready to receive variables. But before we write the code for adding variables, let’s close the loop and work on the OI_DefineEnd script, which will clean up variables and prepare us for the next object description.
// OI_DefineEnd Script
// Get a handle to the manager
var manager = global.OI_Manager;
// Warning if the functions is being misused
if (manager._currObject == noone)
{
show_debug_message("Warning: OI_DefineEnd was called with no prior call to OI_DefineBegin.");
return;
}
// Unset the current object
manager._currObject = noone;
manager._currIndex = 0;
Above we are checking to see if the function is being used correctly, and if it is, we are unsetting the current object and resetting the current variable index.
Finally, we will write what is arguably the most important part of this system, the OI_Add script. Note that this script will be changed slightly later on to account for variable types that require more information, such as enumerators and data structures, but let’s keep it simple for now. Our aim is to take in the variable name and type passed in and set it in the correct position in the map entry and the 2D array inside. Here is the code:
// OI_Add Script
// Get the parameters
var varName = argument[0];
var type = argument[1];
// Get a handle to the OI_Manager
var manager = global.OI_Manager;
// Warning if the functions is being misused
if (manager._currObject == noone)
{
show_debug_message("Warning: OI_Add was called without calling OI_DefineBegin first.");
return;
}
// Set the variable name and type in the current index of the array, in the object map slot.
var definition = manager._objTypeMap[? manager._currObject];
definition[@ manager._currIndex, 0] = varName;
definition[@ manager._currIndex, 1] = type;
// Advance the index
++manager._currIndex;
We will grab the first two variables, using the array notation this time around, since later on we might get more than two variables, so we need the flexibility. We then do error checking which should look familiar by now. Last, we find the map slot for this object, access the current array index, and set the variable name in the first slot, followed by the variable type in the second slot. We then advance the current index and wait for more input.
That is all! We are now ready to describe our objects and keep a database handy to access at run time. Let’s make a test object to see how it should be used. I called mine oPlayerTest. In the create event, set all your variables and descriptions like this:
// oPlayerTest Create Event
if (OI_DefineBegin(object_index))
{
OI_Add("_hp", OI_VarTypes.Int);
OI_Add("_name", OI_VarTypes.String);
OI_Add("_isMoving", OI_VarTypes.Bool);
OI_Add("_mana", OI_VarTypes.Float);
OI_DefineEnd();
}
_hp = 10;
_name = "Terry";
_isMoving = false;
_mana = 15.3;
It doesn’t matter if you describe the object first and declare the variables later, or vice-versa. It works either way, but I prefer it this way. To avoid trying to add variables on an object that has already been described, wrap the entire thing inside an if-statement, with the condition being the return value from OI_DefineBegin (it will work if you don’t, but you will see all those warning messages we wrote).
The begin script takes the object index as a parameter. You can remove it and grab the object_index from the script itself, but if you do it this way, you can have object descriptions anywhere in your code, not just the create event of that object.
Inside the conditional, we will add all the variables we want to keep track of. It doesn’t have to be every variable, but in this case it is. We need to pass two parameters for these basic types, which is the name of the variable as a string, and then the enum value corresponding to the data type. Remember that the string name has to be exactly the same as the variable, or else it won’t work when trying to retrieve the value. Once we are done adding variables, we have to call OI_DefineEnd() to close the loop, and we are done.
Great! Now we have a way to build our database of objects and their types, so let’s move on to doing something useful with it. The easiest way to test it is working correctly is to create an inspect function that draws all the variables and their values to the GUI. We will start with the generic function the user will call, and it will handle every data type itself. Here’s what the OI_Inspect function looks like:
// OI_Inspect Script
// Get the parameters
var instance = argument0;
// Get a handle to the manager
var manager = global.OI_Manager;
// Get the object index from the instance
var object = instance.object_index;
var definition = manager._objTypeMap[? object];
if (!is_array(definition))
{
show_debug_message("Warning: The object hasn't been defined yet.");
return;
}
for (var i = 0; i < array_height_2d(definition); ++i)
{
var varName = definition[i, 0];
// Get the variable value, if it exists
var value;
if (variable_instance_exists(instance, varName))
value = variable_instance_get(instance, varName);
else
{
show_debug_message("Warning: The variable \""
+ varName
+ "\" does not exist in the "
+ object_get_name(instance.object_index)
+ " object.");
return;
}
switch(definition[i, 1])
{
case OI_VarTypes.Int: OI_InspectInt(varName, value); break;
case OI_VarTypes.Float: OI_InspectFloat(varName, value); break;
case OI_VarTypes.Bool: OI_InspectBool(varName, value); break;
case OI_VarTypes.String: OI_InspectString(varName, value); break;
}
++manager._currIndex;
}
manager._currIndex = 0;
It may look like a long function, but it is mostly doing error checking. The important sections are where we grab the object description of the instance we passed in. We then loop through every variable, grabbing its value, and then we call the appropriate specific inspect function depending on the variable type. We do this by using a switch statement on the variable type and passing in the value. The function currently doesn’t work recursively, but we will fix that in part 2 of the article.
We are almost there! We now have to create a function for every data type we want to inspect. It may seem like a lot, but in most projects the number will be smaller than the number of objects, if we had taken that approach from the beginning. I will list all the functions below since most are just one line.
OI_InspectInt
// Get the parameters
var name = argument0;
var value = argument1;
// Draw the variable to the GUI
draw_text(10, 15 * global.OI_Manager._currIndex, name + ": " + string(floor(value)));
OI_InspectFloat
// Get the parameters
var name = argument0;
var value = argument1;
// Draw the variable to the GUI
draw_text(10, 15 * global.OI_Manager._currIndex, name + ": " + string(value));
OI_InspectBool
// Get the parameters
var name = argument0;
var value = argument1;
// Draw the variable to the GUI
draw_text(10, 15 * global.OI_Manager._currIndex, name + ": " + (value ? "True" : "False"));
OI_InspectString
// Get the parameters
var name = argument0;
var value = argument1;
// Draw the variable to the GUI
draw_text(10, 15 * global.OI_Manager._currIndex, name + ": " + value);
Excuse the magic numbers I used to position the items. This was just a quick fix, but you should find a more robust solution if you wish to use this specific functionality in your project.
Last, we need to call this function in the Draw GUI event and pass it an instance id. We will do it inside the Draw GUI event of the oPlayerTest object for simplicity. It should look like:
// oPlayerTest Draw GUI
OI_Inspect(id);
I added some extra code to inspect each object only when you click on it, but I won’t show how to do that here. Instead, feel free to download the demo to try it yourself, but it’s quite trivial.
I hope this example was useful to see the possibilities having a database like this one opens up. We could write a serialize/deserialize function that works the same way, or maybe add the variables of objects to a menu system (like ImGUI, with the ImGuiGML extension created by @babyj3ans).
Before I close this article, I want to show you how to add a more complex variable to the system as a sneak peek of what the second part of this article will be about.
We will now add enumerators to our system. Enums hold an integer value, represented as a string which is easier for humans to read. The issue is that there is no good way of grabbing that string from the integer, so we will need to pair them together. The first change will be in the oPlayerTest object, which now looks like this:
// oPlayerTest Create Event - With Enums
enum StatesPlayer { Idle, Run, Walk, Attack, Hurt, Dead } var statesArray = [ "Idle", "Run", "Walk", "Attack", "Hurt", "Dead" ];
if (OI_DefineBegin(object_index))
{
OI_Add("_hp", OI_VarTypes.Int);
OI_Add("_name", OI_VarTypes.String);
OI_Add("_isMoving", OI_VarTypes.Bool);
OI_Add("_mana", OI_VarTypes.Float);
OI_Add("_state", OI_VarTypes.Enum, statesArray);
OI_DefineEnd();
}
_hp = 10;
_name = "Terry";
_isMoving = false;
_mana = 15.3;
_state = StatesPlayer.Idle;
The sections you see bolded are the ones we added. Having to maintain an enum and an array of strings that match it is not the best choice, since you can forget to sync them leading to issues, but GameMaker doesn’t have a good way of doing this. Just keep both updated and the same moving forward. Next, we will pass the array as a second parameter to the OI_Add script. Last, we will add a variable that uses the enum to test.
Our next change happens in the OI_Add, where we will take in every variable after the first two and save them in an array which will be helpful for passing extra information for more complex data structures.
// OI_Add - With Enums
// Get the parameters
var varName = argument[0];
var type = argument[1];
var extras = 0; for (var i = 2; i < argument_count; ++i) extras[i] = argument[i];
// Get a handle to the OI_Manager
var manager = global.OI_Manager;
// Warning if the functions is being misused
if (manager._currObject == noone)
{
show_debug_message("Warning: OI_Add was called without calling OI_DefineBegin first.");
return;
}
// Set the variable name and type in the current index of the array, in the object map slot.
var definition = manager._objTypeMap[? manager._currObject];
definition[@ manager._currIndex, 0] = varName;
definition[@ manager._currIndex, 1] = type;
if (is_array(extras)) for (var i = 2; i < array_length_1d(extras); ++i) definition[@ manager._currIndex, i] = extras[i];
// Advance the index
++manager._currIndex;
Just like before, the bold text shows the newly added code. We store any extra variables passed in an array, and we later add that information as part of the array entry for that variable to be used later. We then add this new variable type to the switch statement inside OI_Inspect and pass the array of strings as the second parameter, like so:
case OI_VarTypes.Enum: OI_InspectEnum(varName, value, definition[i, 2]); break;
And the OI_InspectEnum function is quite similar to the rest, looking like this:
// Get the parameters
var name = argument0;
var value = argument1;
var strings = argument2;
// Draw the variable to the GUI
draw_text(10, 15 * global.OI_Manager._currIndex, name + ": " + strings[value]);
Now, when we inspect our object, we should see the correct value displayed as a string.
In part two of this article, we will add more data structures, such as arrays, lists, and maps, and implementing the ability to pass another object as a parameter, allowing for recursive inspection. I will also cover how to write serialize/deserialize functions using this system, so keep an eye out for it.
I hope this article proved useful! This is a powerful system that can be expanded and tailor it to your project’s needs. If you have questions, feel free to contact me on Twitter (@AleHitti). Also, make sure you follow me to be notified when I release new articles. Until next time!
Alejandro Hitti is a videogame Programmer and Designer from Venezuela. Although his background is in C++ and working using custom-made game engines, his two commercial games, INK and HackyZack, were made using GameMaker Studio 1.4. With the release of GameMaker Studio 2, that became his engine of choice. The novelty of GMS2, paired with his knowledge of the previous version, ignited his interest to create tutorials that focus on this new engine.