Skip to content

getting_started_sample

Francisco Dias edited this page Apr 10, 2026 · 3 revisions

Getting Started: A Sample Extension

This page walks through creating a minimal extgen project from scratch. By the end, you will have a working Windows extension with a function that takes a callback, a typed struct, and a typed enum - covering the most common GMIDL patterns in a small, self-contained example.


What you will build

A Counter extension that:

  • Keeps a running total.
  • Returns results as a typed struct (CounterResult).
  • Reports its state via a typed enum (CounterStatus).
  • Accepts a GML callback on increment.

Prerequisites

  • extgen installed and on PATH
  • CMake 3.25+
  • Visual Studio 2022

Project Layout

Run extgen with --init to create the project folder. The argument is the full path to where the CMake project will live - extgen writes config.json and extgen.schema.json directly into that folder.

extgen --init path/to/counter

This generates:

counter/
  config.json
  extgen.schema.json

config.json is the starting point for configuration. extgen.schema.json enables editor auto-complete and is rewritten automatically on each run.


config.json

{
  "$schema": "./extgen.schema.json",
  "name": "Counter",
  "gmidl": "spec.gmidl",
  "targets": {
    "windows": {
      "enabled": true
    }
  },
  "gamemaker": {
    "wrappers": {
      "output": "../scripts/Counter_api/Counter_api.gml"
    },
    "extension": {
      "mode": "patch",
      "path": "../Counter.yy"
    }
  }
}

The wrappers.output path is relative to config.json. Adjust it to match your GameMaker project layout.

extension.mode = patch means extgen edits an existing GameMaker extension asset rather than creating a new one. Set extension.path to the .yy file of the extension you have already created in GameMaker.


spec.gmidl

Create spec.gmidl in the same folder as config.json.

[gml_api]
module Counter;

[global, bind = typed_gml]
enum CounterStatus
{
    Ok      = 0,
    Clamped = 1,
    Error   = 2,
}

[global]
class `[[CounterResult]]` prototype Object
{
    [field]
    property total : int32 { get; set; }

    [field, type_hint = `CounterStatus`]
    property status : gmval { get; set; }
}

[global, bind = typed_gml]
function counter_reset() : unit;

[global, bind = typed_gml, type_hint = `CounterResult`]
function counter_add(amount : int32, callback : func) : gmval;

[global, bind = typed_gml]
function counter_get() : int32;

What this defines

Symbol Kind Notes
CounterStatus Enum Strongly typed in GML via bind = typed_gml
CounterResult Struct/class Returned to GML as a typed struct
counter_reset Function Resets the counter; returns nothing
counter_add Function Adds to the counter; fires a callback; returns CounterResult
counter_get Function Returns the current total as int32

counter_add uses two advanced GMIDL features:

  • callback : func - a GML function reference, passed as GMFunction in C++
  • type_hint = `CounterResult` on the function attribute - tells extgen the return value is a CounterResult struct

Generating the code

Run extgen once to generate the rest:

extgen --config 'path/to/the/config.json'

After generation, the folder looks like:

project/
  config.json
  spec.gmidl
  CMakeLists.txt
  CMakePresets.json
  code_gen/
    core/
      GMExtUtils.h
      GMExtWire.h
    native/
      CounterInternal_native.h
      CounterInternal_native.cpp
      CounterInternal_exports.h
  src/
    CMakeLists.txt
    native/
      Counter_native.cpp   <- you write this

Note

Everytime you make changes to the config.json file of to the spec.gmidl file you must to re-run the tool.


What extgen generates

code_gen/core/

These files are shared across all native targets. Do not hand-edit them.

  • GMExtWire.h - marshaling helpers, type conversions, GMWIRE_THROW, GMFunction, GMBuffer
  • GMExtUtils.h - utility macros

code_gen/native/

Generated for the native target (Windows, macOS, Linux, consoles).

  • CounterInternal_native.h - declares the C++ structs and free functions your implementation must provide:

    struct CounterResult {
        int32_t total{};
        CounterStatus status{};
    };
    
    enum class CounterStatus : int32_t {
        Ok      = 0,
        Clamped = 1,
        Error   = 2,
    };
    
    void counter_reset();
    CounterResult counter_add(int32_t amount, gm::wire::GMFunction callback);
    int32_t counter_get();
  • CounterInternal_native.cpp - generated bridge layer that unpacks GML values, calls your functions, and packs the results back. You do not edit this.

  • CounterInternal_exports.h - the DLL export table. You do not edit this.

GML output

extgen also writes a .gml file to the path you specified in wrappers.output. This file contains GML wrapper functions and the typed constructor for CounterResult.


src/native/Counter_native.cpp

This is the only file you write. It implements the three functions declared in CounterInternal_native.h.

#include "native/CounterInternal_native.h"

static int32_t g_total = 0;
static const int32_t k_max = 1000;

void counter_reset()
{
    g_total = 0;
}

CounterResult counter_add(int32_t amount, gm::wire::GMFunction callback)
{
    CounterResult result;

    g_total += amount;

    if (g_total > k_max)
    {
        g_total      = k_max;
        result.status = CounterStatus::Clamped;
    }
    else
    {
        result.status = CounterStatus::Ok;
    }

    result.total = g_total;

    // Fire the GML callback with the current total.
    // call() is thread-safe - safe to invoke from a worker thread.
    callback.call(result.total);

    return result;
}

int32_t counter_get()
{
    return g_total;
}

Notes

  • GMFunction::call() accepts numeric scalars, std::string, std::string_view, const char*, and custom structs/enums.
  • The callback fires asynchronously here. The call will queue the execution and GameMaker runtime will execute it on the next frame.
  • g_total is a process-global here for simplicity. For a real extension with multiple instances, use a handle-based design - store state in a map keyed on an ID, and put that ID in a struct field.

src/CMakeLists.txt

extgen generates a starter src/CMakeLists.txt. For a Windows-only extension it looks like this:

set(SRC_DIR "${CMAKE_CURRENT_SOURCE_DIR}")

if(WIN32)
  file(GLOB TARGET_SRCS CONFIGURE_DEPENDS "${SRC_DIR}/native/*.cpp")
endif()

target_sources(${PROJECT_NAME} PRIVATE ${TARGET_SRCS})

No changes are needed for this sample. Add more file(GLOB ...) branches when you add more platform targets.


Build (Windows)

Visual Studio Code

  1. Edit the configuration

  2. Selecte Windows x64 (you can use either Debug or Release)

  3. Press the build button (note your configs will be different than then ones present in the image below, it's okay)

Command Line Interface

1. Configure

cmake --preset windows-debug

Or for release:

cmake --preset windows-release

2. Build

cmake --build --preset windows-debug

3. Output

The post-build step copies the DLL to the extension output folder next to your .yyp.

File Description
Counter.dll Windows shared library, copied to extension folder

Using the extension in GML

After building, open GameMaker and run the project. The generated wrappers provide:

counter_reset();

var result = counter_add(10, function(total) {
    show_debug_message("Callback fired, total = " + string(total));
});

// result is a CounterResult struct - returned synchronously
show_debug_message(result.total);     // 10
show_debug_message(result.status);    // CounterStatus.Ok

var current = counter_get();          // 10

result is returned synchronously. The callback always fires on the next GameMaker frame - even when callback.call() is invoked immediately from the C++ side.

CounterStatus is a strongly-typed GML enum because the GMIDL declares it with bind = typed_gml. Comparisons like result.status == CounterStatus.Ok work with full editor auto-complete.


Next steps