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
*Vec3and anoutparameter 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.