Skip to main content

Game state

GameMaker keeps its state in globals, in the variables of live instances, and in data structures it hands out by id. CoreLoader reaches all of it through the game's own builtins, so every read and write here is a call into the game. That means two rules apply to everything on this page: call from the game thread, and expect a GmlException when the thing you ask about does not exist yet (at the title screen, say). See Robustness and testing.

Read and write a global variable​

Use Globals.Get, Globals.Set and Globals.Exists. They are variable_global_get, variable_global_set and variable_global_exists underneath. Stoneshard's world map keeps the player's grid cell in two globals:

public static (int X, int Y) PlayerCell =>
((int)Globals.Get("playerGridX").AsReal, (int)Globals.Get("playerGridY").AsReal);
Source: managed/Mods/FastTravel/WorldMap.cs
private static void SetCell((int X, int Y) cell)
{
Globals.Set("playerGridX", cell.X);
Globals.Set("playerGridY", cell.Y);
}
Source: managed/Mods/FastTravel/Traveller.cs

Globals.Get returns an RValue. Check IsNumber (or Kind) before AsReal, because a global the game has not set yet reads as undefined. The StoneshardHarness mod does exactly that when it keeps a value in a game global of its own:

public static int TrackedRoom
{
get => Globals.Get(RoomGlobal) is { IsNumber: true } v ? (int)v.AsReal : -1;
set => Globals.Set(RoomGlobal, value);
}
Source: managed/Mods/StoneshardHarness/World.cs

Globals.Names() lists every global, which is how you discover what the game has.

Find a singleton instance and edit its variables​

Many games keep their state on one controller object. Find the object by name with GmlObject.Find, take its first live instance with Instance(0), and read and write variables on the resulting InstanceRef. Dwarf Eats Mountain keeps gold, mithril and soul on oSys:

private InstanceRef? Sys()
{
var o = GmlObject.Find("oSys");
return o is { } obj && obj.InstanceCount > 0 ? obj.Instance(0) : null;
}
Source: managed/Mods/DwarfBoost/DwarfBoost.cs
double gold = sys.Get("gold").AsReal;
// ...
sys.Set("gold", gold);
Source: managed/Mods/DwarfBoost/DwarfBoost.cs and line 82

With the generated interop, the lookup collapses to Objects.oSys.First, and the variable names are constants under Objects.oSys.Vars, harvested from live instances while the game ran:

if (Objects.oSys.First is { } sys)
UI.Text($"gold (oSys.gold): {sys[Objects.oSys.Vars.gold].AsReal:N0}");
Source: managed/Examples/InteropExample/InteropExample.cs

InstanceRef also has Has(name) to test for a variable, VariableNames() to list them, and an indexer (sys["gold"]) that is the same as Get and Set.

Gotchas:

  • GmlObject.Find is cached per name, and a miss is remembered for two seconds, so polling an object that does not exist yet every frame stays cheap.
  • An InstanceRef is safe to keep across frames: a destroyed instance stops Existsing instead of dangling. An Instance (the pointer you get as HookCall.Self) is not; it is only valid during the callback. To turn a ref into an Instance for a call that needs one, use Resolve() each frame and expect null.
  • A variable the instance does not have reads as undefined; check with Has first.

Iterate every instance of an object​

GmlObject.Instances() yields a ref for each live instance, including instances of child objects. Dwarf Eats Mountain's units (miners, flamers, harpoons, cannons) are all children of parDwarf, so one loop over the parent covers every unit:

if (GmlObject.Find("parDwarf") is not { } units) return;
var seen = new HashSet<long>();
foreach (var unit in units.Instances())
{
if (!unit.Has("damage")) continue;
long key = unit.Id.Int64;
seen.Add(key);
double current = unit.Get("damage").AsReal;
if (double.IsNaN(current)) continue;
Source: managed/Mods/DwarfBoost/DwarfBoost.cs

The typed form is the same loop over Objects.o_enemy.Object, with variable names from Vars:

if (Objects.o_enemy.Object is { } enemies)
{
foreach (var e in enemies.Instances())
{
if (!e.Exists || Num(e, Objects.o_enemy.Vars.is_hostile) <= 0 || Num(e, Objects.o_enemy.Vars.HP) <= 0) continue;
if (Math.Max(Math.Abs(Num(e, "x") - px), Math.Abs(Num(e, "y") - py)) <= range)
return "there are enemies nearby";
}
}
Source: managed/Mods/FastTravel/Traveller.cs

Gotchas:

  • Because children are included, an exact-object test is sometimes needed. Stoneshard's o_inventory list also returns other containers, so the Trials mod compares object_index with the object it wants:

    if (Objects.o_inventory.Object is not { } obj) return null;
    foreach (var r in obj.Instances())
    if (Builtins.object_get_name(r.Get("object_index")).ToString() == Objects.o_inventory.Name) return r;
    Source: managed/Mods/StoneshardTrials/World.cs
  • Instances() is lazy and asks the game for the n-th instance as you go. If your loop creates or destroys instances, take a ToList() first (see the next recipe).

  • Each Get is a call into the game. For hundreds of instances, read what you need once per pass and throttle the pass (see Throttle OnUpdate).

Spawn and destroy an instance​

Use the instance_create_depth and instance_destroy builtins, and wrap the new id in an InstanceRef to set variables on it. The Trials mod spawns tavern traders:

var obj = GmlObject.Find(t.Object) ?? throw new InvalidOperationException($"no {t.Object}");
var npc = new InstanceRef(Builtins.instance_create_depth(t.X, t.Y, -t.Y, obj.Index));
if (!npc.Exists) return null;
// Our own stock key, before anything reads the town trader's.
npc.Set("id_name", t.Key);
Source: managed/Mods/StoneshardTrials/Merchants.cs

and removes a duplicate one:

foreach (var n in npcs.Instances().ToList())
{
if (n.Get("id_name") is not { Kind: RValueKind.String } k || k.ToString() != key) continue;
if (first == null) first = n;
else Builtins.instance_destroy(n.Id);
}
Source: managed/Mods/StoneshardTrials/Merchants.cs

Without the interop, the same two calls are Game.CallBuiltin("instance_create_depth", x, y, depth, obj.Index) and Game.CallBuiltin("instance_destroy", id).

Gotchas:

  • Check Exists right after creating: a refused spawn leaves you with an id that names nothing.
  • A new instance has run only its Create event. A game that reads variables another event sets may need you to run that event; see Run an object event directly.

Read a ds_map​

Many games keep their real state in ds_maps and ds_lists and hand out only the id. DsMap wraps that id (Exists, Count, Get, Set, Has, Remove, Keys(), Entries(), ToJson()). The struct is safe to keep across frames; a destroyed map stops Existsing. Strings read out of one are pooled like any other value (see Values).

Stoneshard saves each visited room's state in a map keyed by "x_y"; FastTravel reads the keys to know where the player has been:

var rooms = new DsMap(Globals.Get("locationsRoomsDataMap"));
if (rooms.Exists)
{
foreach (var (key, _) in rooms.Entries())
{
var parts = key.Split('_');
if (parts.Length == 2 && int.TryParse(parts[0], out int x) && int.TryParse(parts[1], out int y)) set.Add((x, y));
}
}
Source: managed/Mods/FastTravel/WorldMap.cs

Other structures have no wrapper but are one builtin call away. Stoneshard's fog of war is a ds_grid:

var fog = Globals.Get("globalmapFogGrid");
return fog.IsNumber && Builtins.ds_grid_get(fog, cell.X, cell.Y).AsReal > 0;
Source: managed/Mods/FastTravel/WorldMap.cs

Edit a ds_list inside a ds_map​

DsList wraps a list id (Count, At, Set, Add, Insert, RemoveAt, Clear, Items()). When a list lives inside a map, edit the list in place and never write its id back with Set: the map already holds the reference. The Trials mod rewrites a potion's effect list this way:

var data = new DsMap(bottle.Get("data"));
if (!data.Exists) throw new InvalidOperationException("the bottle has no data map");
// A ds_list held in the map: edited in place, never replaced.
var list = new DsList(data.Get("atrdlist"));
if (!list.Exists) throw new InvalidOperationException("the bottle has no atrdlist");
list.Clear();
foreach (var tag in tags) list.Add(tag);
Game.CallScriptAs(bottle, bottle, "scr_potion_set_param");
// A new potion shows "?" until identified (its data's identified); a gift comes known.
data.Set("identified", 1);
Source: managed/Mods/StoneshardTrials/Cards/Effects.cs

Always test Exists after wrapping: a missing variable reads as undefined, and an id that is not a map or list does not exist. DsMap.Set is ds_map_replace, so it adds or replaces.

Read arrays and structs​

GML arrays and structs are values, not ids. Use the Gml helpers: ArrayLength, ArrayGet, StructGet, StructSet, StructNames, and TypeOf to tell them apart. The Trials mod reads Stoneshard's list of dungeons, an array of structs:

var arr = Run(Scripts.scr_glmap_getLocationBySubType, kind);
int n = Gml.ArrayLength(arr);
for (int i = 0; i < n; i++)
{
var s = Gml.ArrayGet(arr, i);
int x = (int)Gml.StructGet(s, "x").AsReal, y = (int)Gml.StructGet(s, "y").AsReal;
Source: managed/Mods/StoneshardTrials/World.cs

The Console's dump walks a value of unknown shape with TypeOf, ArrayGet and StructNames:

if (depth < 3 && v.Kind == RValueKind.Array)
{
int n = Gml.ArrayLength(v);
sb.AppendLine($"{pad}{name} = [array of {n}]");
for (int i = 0; i < n && i < 100; i++) DumpValue(sb, $"[{i}]", Gml.ArrayGet(v, i), depth + 1);
}
else if (depth < 3 && type == "struct")
{
sb.AppendLine($"{pad}{name} = {{struct}}");
foreach (var m in Gml.StructNames(v).Take(100)) DumpValue(sb, m, Gml.StructGet(v, m), depth + 1);
}
Source: managed/Mods/Console/Inspector.cs

Gotchas:

  • Arrays and structs the game hands you are pooled and released at the end of the frame. To keep one across frames, use Values.Keep and later Values.Free; see Values.
  • Cap loops over structures you do not know, as Take(100) does above: a game's data can be huge.

Walk the object table and hierarchy​

GmlObject.All() needs one builtin call per asset index, thousands in a big game. The first call does that walk and caches it for the session. ObjectTable.Start() builds the whole table (parents included) over a few frames instead, and ObjectTable.Ready, Progress and Status report how far it got, which suits a progress bar:

public override void OnInitialize() => ObjectTable.Start();

public override void OnGUI()
{
if (!ObjectTable.Ready) { UI.ProgressBar(ObjectTable.Progress, 0f, ObjectTable.Status); return; }
var food = GmlObject.Find("o_inv_food_parent")!.Value;
foreach (var o in food.Children()) UI.Text(o.Name);
bool isFood = GmlObject.Find("o_inv_acorn")?.IsA("o_inv_food_parent") == true;
}

Parent, Ancestors() and IsA(name) work before the table is ready, by asking the runtime directly. Children() needs every object's parent, so before Ready it finishes the table on the spot, in one frame, and ObjectTable.Complete() does that on demand. The StoneshardCheats catalogue calls ObjectTable.Start(), then waits for Ready over later frames before reading GmlObject.All() (Catalogue.cs lines 119-147).

Scale a value without compounding it​

If you multiply a game value every frame, the value grows without bound. Remember what the game last computed, and recognise your own write when you see it. DwarfBoost scales each unit's damage, which the game recomputes from baseDamage whenever an upgrade changes it. Each instance remembers the value the mod wrote:

// instance id -> (the game's own damage, what we wrote, the unit's baseDamage then)
private readonly Dictionary<long, (double Base, double Written, double BaseDamage)> _damage = new();
Source: managed/Mods/DwarfBoost/DwarfBoost.cs
double baseDamage = unit.Has("baseDamage") ? unit.Get("baseDamage").AsReal : double.NaN;
bool ours = _damage.TryGetValue(key, out var d)
&& Math.Abs(current - d.Written) < 1e-6
&& (double.IsNaN(baseDamage) || baseDamage.Equals(d.BaseDamage));

double gameValue = ours
? d.Base // still our own value: the game has not recomputed
: current; // new instance or the game recomputed: that is the base now

double want = gameValue * _damageMultiplier;
if (Math.Abs(want - current) > 1e-6) unit.Set("damage", want);
_damage[key] = (gameValue, want, baseDamage);
Source: managed/Mods/DwarfBoost/DwarfBoost.cs

The same trick applies to income: DwarfBoost multiplies only the frame's rise in gold, so spending is untouched, and resets its last reading when it edits gold itself so its own edit is not counted as income (DwarfBoost.cs lines 70-87).

If a script's argument is what you want to scale, you do not need any of this: HookCall.SetArg changes only what the original receives (see Change a script's argument).