Modules & Imports

GX modules let you split code across files and organize reusable functionality. Unlike headers in C, modules are first-class — the compiler understands them, enforces namespaces, and handles build configuration automatically.

Declaring a Module

The first line of a module file declares its name:

// math_utils.gx
module math_utils

fn clamp:f32(value:f32, lo:f32, hi:f32) {
    if (value < lo) { return lo }
    if (value > hi) { return hi }
    return value
}

fn lerp:f32(a:f32, b:f32, t:f32) {
    return a + (b - a) * t
}

Every file with module name contributes to that module. You can split a module across many files if the module gets large.

Importing a Module

Use import to bring a module into your file:

// main.gx
import math_utils

fn main() {
    var x = math_utils.clamp(150.0, 0.0, 100.0)
    print("clamped: {x}\n")     // 100

    var mid = math_utils.lerp(0.0, 10.0, 0.5)
    print("midpoint: {mid}\n")  // 5
}

Module functions are accessed with module_name.function_name — the dot namespaces them so there’s no clash with other modules or your own code.

Build the Project

Point the compiler at your main file with -I to say where modules live:

gx main.gx -I path/to/modules -o myapp

The compiler finds math_utils.gx in the modules directory, loads it, and links everything together.

Sub-Modules with Dotted Names

Modules can be nested. A dotted name maps to a directory path:

modules/
  raylib/
    gx/
      core.gx      → module raylib.core
      shapes.gx    → module raylib.shapes
      text.gx      → module raylib.text

Import a specific sub-module:

import raylib.core
import raylib.shapes
import raylib.text

fn main() {
    InitWindow(800, 600, "Demo")
    while (!WindowShouldClose()) {
        BeginDrawing()
        ClearBackground(RAYWHITE)
        DrawCircle(400, 300, 50.0, BLUE)
        DrawText("Hello", 10, 10, 20, BLACK)
        EndDrawing()
    }
    CloseWindow()
}

Each sub-module pulls in only the symbols it defines — you can import just what you need.

Practical Example: Splitting a Program

vec.gx:

module vec

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

fn vec3_add:Vec3(a:Vec3, b:Vec3) {
    return Vec3{a.x + b.x, a.y + b.y, a.z + b.z}
}

fn vec3_length_sq:f32(v:Vec3) {
    return v.x * v.x + v.y * v.y + v.z * v.z
}

main.gx:

import vec

fn main() {
    var a = vec.Vec3{1.0, 2.0, 3.0}
    var b = vec.Vec3{4.0, 5.0, 6.0}
    var sum = vec.vec3_add(a, b)

    print("sum: ({sum.x}, {sum.y}, {sum.z})\n")
    print("length^2: {vec.vec3_length_sq(sum)}\n")
}

Build:

gx main.gx -I . -o myapp

Module Visibility

Every function in a module is accessible to code that imports it. There’s no public/private distinction — if it’s in the module, it’s callable. For internal-only helpers, prefix their name with an underscore by convention:

module parser

fn parse_int:i32(s:str) {
    return _parse_digits(s, 0)
}

// Convention: underscore prefix = internal helper
fn _parse_digits:i32(s:str, start:i32) {
    // ...
    return 0
}

Main File Is Also a Module

Your main file implicitly participates in the build. You can declare module main explicitly, but it’s not required — if omitted, the file uses the default module.

Practical Example: Multi-File Game

src/
  main.gx          (entry point)
  game.gx          (module game — logic)
  rendering.gx     (module rendering — draw calls)
  input.gx         (module input — keyboard/mouse)

main.gx:

import game
import rendering
import input

fn main() {
    game.init()
    while (game.running()) {
        input.poll()
        game.update()
        rendering.draw()
    }
    game.shutdown()
}

Each module owns its own concerns. Changes to rendering.gx don’t force recompilation of game.gx.

Try it — Create a greetings module with a say_hello(name:str) function and import it from main. Build with gx main.gx -I . locally. (The Playground is single-file, so this one needs a local build.)


Expert Corner

How the module loader works: When the compiler sees import foo.bar, it searches each -I include directory for foo/gx/bar.gx. The dotted name maps directly to a path. Multiple include directories are searched in order — later ones override earlier ones if the same module is found twice.

Modules are compile units, not link units: Unlike C++ where each .cpp file produces a separate object file, GX modules are all parsed together and emitted as a single C file. This gives the compiler full visibility across module boundaries for inlining, constant folding, and type checking.

Why no visibility modifiers: GX avoids public/private/protected to keep the language simple. The underscore-prefix convention is borrowed from Python — simple, universal, and enforced by code review rather than the compiler. For a systems language with small teams, this works well. For huge codebases with strict encapsulation needs, it’s a limitation.

Module namespace is flat within a file: import foo.bar makes foo.bar.* accessible, not foo.*. You can’t write foo.anything_else_in_foo — each sub-module is loaded separately. This is intentional: it forces explicit imports and avoids implicit dependencies.

Circular imports are not allowed: If module A imports B, B cannot import A (directly or transitively). The compiler detects cycles during module loading and reports an error. The solution is to factor out the shared code into a third module that both import.

Main file discovery: The file passed on the command line is the “root” of the build. All its imports are transitively loaded. Files that aren’t reached by imports from the root are not compiled, even if they’re in the include path.

Module C files: A module can include C source files via @cfile("path/to/file.c"). The compiler bundles them into the build automatically. This is how modules like clay, microaudio, and json ship their C implementations alongside the GX bindings.

Comparison to C++ modules: C++20 added import std; which is nice but complex and slow. GX modules are closer to Rust’s use or Python’s import: simple text-based file resolution, explicit names, no binary module interface files. Fast, predictable, no magic.