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
greetingsmodule with asay_hello(name:str)function and import it from main. Build withgx 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.