Functions

Functions are how you organize code in GX. They take inputs, do work, and (optionally) return a value. GX adds a few twists to protect you from common C mistakes.

Basic Functions

A function declaration starts with fn, then the name, return type after :, and parameters in parentheses:

fn add:i32(a:i32, b:i32) {
    return a + b
}

fn main() {
    var sum = add(10, 20)
    print("sum = {sum}\n")
}

If the function doesn’t return anything, omit the return type:

fn greet(name:str) {
    print("Hello, {name}!\n")
}

fn main() {
    greet("GX")
}

Recursion

Functions can call themselves. The classic example is factorial:

fn factorial:i32(n:i32) {
    if (n <= 1) {
        return 1
    }
    return n * factorial(n - 1)
}

fn main() {
    for (i = 1:6) {
        print("{i}! = {factorial(i)}\n")
    }
}

Multiple Return Values via out

GX functions return a single value. For multiple results, use out parameters — the function writes through them:

fn safe_divide:bool(a:f32, b:f32, out result:f32) {
    if (b == 0.0) {
        return false
    }
    *result = a / b
    return true
}

fn main() {
    var r:f32 = 0.0
    if (safe_divide(10.0, 3.0, &r)) {
        print("10 / 3 = {r}\n")
    }
    if (!safe_divide(5.0, 0.0, &r)) {
        print("division by zero caught\n")
    }
}

The &r syntax passes the address of r. Inside safe_divide, *result = a / b writes through that address.

Read-Only Pointer Parameters

When you pass a pointer to a function, GX treats it as read-only by default. The function can inspect the data but cannot modify it:

struct Vec3 {
    x:f32
    y:f32
    z:f32
}

// No 'out' — this is a const pointer in C
fn vec3_length_sq:f32(v:*Vec3) {
    return v.x * v.x + v.y * v.y + v.z * v.z
}

fn main() {
    var pos = Vec3{3.0, 4.0, 0.0}
    var len_sq = vec3_length_sq(&pos)
    print("length^2 = {len_sq}\n")
}

If vec3_length_sq tried to write v.x = 0.0, the compiler would reject it with “Cannot write through read-only pointer.” This protects you from accidentally mutating inputs.

The out Keyword for Mutable Pointers

To let a function modify data, prefix the parameter with out:

struct Vec3 {
    x:f32
    y:f32
    z:f32
}

fn vec3_scale(out v:*Vec3, factor:f32) {
    v.x = v.x * factor
    v.y = v.y * factor
    v.z = v.z * factor
}

fn main() {
    var pos = Vec3{1.0, 2.0, 3.0}
    vec3_scale(&pos, 2.0)
    print("({pos.x}, {pos.y}, {pos.z})\n")  // (2, 4, 6)
}

The caller must explicitly pass &pos — you can see at the call site that mutation is happening.

Functions Cannot Return Pointers

This is a hard rule in GX, and it eliminates a whole category of bugs:

// COMPILE ERROR: GX does not allow returning pointers
// fn make_vec:*Vec3() {
//     var v = Vec3{1.0, 2.0, 3.0}
//     return &v  // would return pointer to dead stack variable!
// }

In C, returning &v where v is a local variable is undefined behavior — the stack frame is gone the moment the function returns. GX refuses to compile such code. Use one of these instead:

// Return by value
fn make_origin:Vec3() {
    return Vec3{0.0, 0.0, 0.0}
}

// Or fill an out parameter
fn make_unit_x(out v:*Vec3) {
    v.x = 1.0
    v.y = 0.0
    v.z = 0.0
}

fn main() {
    var origin = make_origin()
    print("origin = ({origin.x}, {origin.y}, {origin.z})\n")

    var x_axis:Vec3
    make_unit_x(&x_axis)
    print("unit_x = ({x_axis.x}, {x_axis.y}, {x_axis.z})\n")
}

Function Pointers

Functions themselves are values you can store and call indirectly:

fn add:i32(a:i32, b:i32) { return a + b }
fn mul:i32(a:i32, b:i32) { return a * b }

fn apply:i32(op:fn(i32,i32):i32, x:i32, y:i32) {
    return op(x, y)
}

fn main() {
    print("{apply(add, 3, 4)}\n")   // 7
    print("{apply(mul, 3, 4)}\n")   // 12
}

Try it — Write a function that takes a *Vec3 and an out parameter to compute the length. Test it in the Playground.


Expert Corner

Why pointers are const by default: In C, every T* parameter is mutable unless marked const T*. Most C functions take non-const pointers even when they only read. GX flips this: pointers are const unless you mark them out. This makes caller intent explicit and eliminates accidental mutation.

Why GX forbids returning pointers: The most common C memory bug is returning a pointer to a local variable — the stack frame dies, the pointer dangles, and you get a crash later with no stack trace. GX’s type checker rejects function signatures like fn foo:*i32() outright. You can still pass pointers INTO functions, you just can’t return them OUT. For “create and return” patterns, use out parameters or return by value.

The & and * operators: &x takes the address of x (produces *T). *p dereferences p (produces T). You can read p.field directly without (*p).field — GX auto-dereferences struct member access for readability.

Out parameters vs return values — when to use which:

  • Return by value for small, simple results (primitives, small structs like Vec3)
  • Out params for large structs (avoids copying), multiple results, or fallible operations where you need a separate success flag
  • The C compiler often optimizes return-by-value into a caller-provided pointer anyway (return value optimization), so don’t worry about performance

Function pointers vs function objects: GX’s function pointers are raw C function pointers — no closures, no captured environment. If you need closures, pass an out context struct alongside the function pointer. This keeps GX’s “no hidden allocations” promise and matches how most C game engines handle callbacks.