← Back to blog
Wolfenstein · · DevDad

Building Wolfenstein in 7,000 Lines of GX

A single .gx file. A real raycaster with RGB lightmaps, dynamic lighting, BFS pathfinding, see-through iron-bar doors, and a boss fight — running native and in your browser from the same source.

showcase raycaster raylib graphics web

I built a Wolfenstein-style game in one file.

Not a tech demo. Not a “raycaster in 200 lines” gist. A real, finished, modern-feeling 3D shooter — three levels, a boss fight, soldier AI that flanks you, RGB-tinted lighting, see-through iron-bar doors that telegraph the goal from a distance, an animated night sky, three weapons, runtime quality presets, full audio with positional SFX, and depenetration-guarded movement so player, soldiers, and the boss never stack inside a wall — at 60 FPS, 800×600, in ~7,000 lines of GX.

Same source builds native (Win/Linux/macOS) and runs in your browser with one CLI flag. No engine. No scene graph. No ECS framework. No external art for walls. Just typed arrays, match statements, a hand-rolled DDA raycaster, and direct calls into raylib through GX’s C interop.

This post is the tour. If you want the full-fat technical breakdown, the Wolfenstein Showcase doc walks every system. This is the highlights reel — the parts that surprised me, the parts I want to brag about, and the parts that I think actually say something about what GX is for.


Why Wolfenstein, of all things

A bit of personal context, because I think it matters for understanding why this is the game I picked.

I grew up on a Commodore 64. Then a ZX Spectrum. Then the family eventually got a beige PC running DOS, and that’s where the universe cracked open: Wolfenstein 3D, DOOM, Duke Nukem 3D, Quake. Those games were my first encounter with the idea that software could be art — that a few people, sometimes one person, could conjure an entire world out of integer math and a 320×200 framebuffer. I didn’t know what a raycaster was. I just knew the corridors felt real, the doors went whoosh, and somebody, somewhere, had figured out how to make a 486 do that.

Thirty years later, I’m building a programming language. And when it came time to prove the language could carry a real project — not a fizzbuzz, not a triangle demo, but a thing — Wolf3D was the obvious target. Three reasons:

1. Sentimental. It’s the game that made me want to write games in the first place. Closing that loop felt right.

2. Technically, it’s the right size for now. Wolf3D is a DDA raycaster with sprites, doors, and AI. That’s a weekend’s worth of well-understood systems on top of a graphics library. DOOM — the natural next target — is a different beast: BSP trees, variable floor/ceiling heights, real level geometry, a proper editor pipeline. That’s a project. Wolf3D is a stress test. I’m still adding features to GX itself; I needed something I could finish, not something that would consume a year while the language was moving under it.

3. It exercises everything that matters. A raycaster touches the type system (typed pixel buffers, distinct numeric widths, no implicit conversions), the C ABI (raylib runs the whole graphics/input/audio layer), control flow (match for tile dispatch and state machines), memory layout (zero malloc in the main loop, static arrays everywhere), comptime (#if (@os == "web") for the wasm port), and the build system (one CLI flag, native or browser). If GX could carry Wolfenstein cleanly, GX could carry the language design decisions I’d made up to that point.

DOOM remake is on the list. It’s the natural next thing. But I needed Wolf3D first — to validate the language, to clear a path through the engine work I already understood, and honestly, to scratch the itch that’s been sitting in the back of my head since I was ten years old typing WOLF3D.EXE at a DOS prompt and watching that pixelated brick wall scroll by.

This blog is what came out the other end.


The 30-second pitch

gx projects/wolfenstein.gx -I modules -O2 -o wolf.exe        # native, ~3s build with TCC
gx projects/wolfenstein.gx -I modules --target web -o wolf.html   # wasm in your browser

Same source. Same modules. Same import "raylib". The only web-specific code in the entire 7,000-line file is one #if (@os == "web") block of ~5 lines (a mouse-delta clamp for a Chrome quirk). Comptime #if makes the native binary literally not contain that branch — zero bloat, zero runtime branching.

The whole project is wolfenstein.gx plus a data/ folder of sprite PNGs. No level data files. Maps are string literals. No art for walls. Wall textures are generated at startup from a hash function. This is what “single file” actually means here.


The stack, such as it is

LayerChoice
LanguageGX → C transpiler (default) or @backend("llvm")
Graphics / input / audioraylib, called directly through extern fn
Native buildbundled TCC (~410 ms clean) or clang with -O2 (~425 ms) / -O3 (~445 ms)
Web build--target web → emscripten → wasm
Memorystatic arrays, zero malloc in the main loop
Assetsprocedural for walls/sky/floor; PNG for entity sprites

That’s the whole tech stack. There is no “engine layer” between the game and raylib’s C ABI. raylib’s calling convention is GX’s calling convention. When you read DrawTexturePro(...) in the source, that’s the literal raylib function being called — same arguments, same registers, same overhead as if you’d written it in C.

A note on the backends, because I want to be straight about where the language is right now: today there are two paths — GX → C (the default, bundled TCC for fast iteration, clang/gcc for shipping) and GX → LLVM IR .ll (handed to clang to finish the job). Both work end-to-end, both produce real binaries, both build wolfenstein. They’re the right tools for now: I’m still rounding out the language surface for 1.0 — comptime, modules, allocators, the numeric/vector story, GPU compute — and emitting C and LLVM-IR text is the pragmatic way to get there without forking attention. Once the language is settled, the natural next step is proper LLVM integration (linking the LLVM C++ API directly, owning the IR module, running our own pass pipeline) instead of shelling out via text. That’ll come after 1.0. The output stays the same; the path to it gets shorter.


Maps are strings. The map parser is a match.

Each level is a 32×32 grid of characters. Walls are digits. Floors are dots. Letters are entities.

const g_map_level1: str[32] = {
    "11111111111111111111111111111111",
    "1P....O1..r2....3...K....b3.S.A1",
    "1...L..1...2.S......3.S......3A1",
    // ...
}

The “level editor” is your text editor. Diffs are readable. There’s no binary blob to merge-conflict over.

The parser is a single match over each character byte:

match (ch) {
    46:     { g_map[idx] = 0 }                       // '.' floor
    49..54: { g_map[idx] = (i32)(ch - 48) }          // '1'-'6' wall texture
    80:     { g_map[idx] = 0; g_px = ...; g_py = ... } // 'P' player spawn
    76:     { /* L medium torch — set color, intensity, radius */ }
    108:    { /* l candle */ }
    75:     { /* K brazier */ }
    // ... 30+ tile kinds ...
}

GX’s match has range patterns (49..54), multi-line arm bodies, and a default arm. Compared to a C switch/case/break ladder or an if-else cascade, the parser reads as a table of behavior rather than a sequence of conditions. Adding a new tile is one arm.

Levels swap by replacing g_map_data’s rows from g_map_level2 / g_map_level3 and rerunning init_map. That’s it. That’s the level system.


Lighting: three buffers, one contract

The lighting is the deepest system in the game and it’s also the cleanest. Three layers stacked, every renderer reads them through one function:

1. Static lightmap (per-cell RGB)g_lightmap_r/g/b: f32[1024]. Computed once at level load. Each light contributes intensity * (1 - (d/r)²) to every cell within radius, scaled by the light’s RGB tint.

2. Static occlusion — every cell-to-light contribution is gated by a DDA ray cast from the light to the cell. Walls block. Iron bars don’t. Closed doors do, open doors don’t. The lightmap reflects “right now” topology.

3. Dynamic lights (per-frame)g_lightmap_dyn_r/g/b: f32[1024]. Zeroed and re-accumulated every frame. Owners include exploding barrels, burning barrels, the boss (when low-HP, registers a fire halo flickering at his feet), and active rockets — yes, a rocket flying down a corridor lights the walls as it passes.

The contract everything reads through:

sample_light(world_x, world_y)
// → fills g_light_r, g_light_g, g_light_b
// = bilinear blend of (static + dynamic) over 4 surrounding cells,
//   with walls forced to AMBIENT to prevent bleed

The floor renderer calls it. The wall renderer calls it. Every sprite calls it. The minimap reads from the same arrays. Adding a colored light to the world is setting one array entry. That’s what I mean by “the lighting layer is the contract.”

The trick that makes walls look right

A wall has two faces. The center-cell brightness is one number. Reading it for both faces makes a wall lit on both sides even when only one side has a torch. The fix is one line:

// Sample the floor neighbor on the side the ray hit, not the wall itself
if (g_ray_side == 0) { lnx = lnx + (rdx > 0 ? -1 : 1) }
else                 { lny = lny + (rdy > 0 ? -1 : 1) }
sample_light(lnx + 0.5, lny + 0.5)

A wall between a torch-lit room and a dark corridor renders bright on the torch side, dim on the corridor side — automatic, no per-face data. One of those fixes that flips the visual quality from “raycaster” to “thing”.

Doors that open and let light spill through

The lightmap is computed once at level load. That’s almost true. The exception is doors — and getting the door-light interaction right was one of those small problems that ate a surprising fraction of an afternoon and ended up being one of my favorite bits of the engine.

The contract: light should pass through an open door, and not through a closed one. Sounds obvious. The hard part is “when does the lightmap actually update?” You don’t want to recompute it every frame (~30 µs is cheap, but ~30 µs × 60 fps × N doors animating = real budget). You don’t want to recompute it never (then the corridor stays dark even though the door is wide open). You want to recompute it exactly when the topology changes.

The trick is a 50% threshold + an edge detector:

var now_blocking = (g_door_timer[i] < 0.5)
if (was_blocking != now_blocking) { lightmap_dirty = true }
if (lightmap_dirty) { compute_lightmap() }

Per door cycle, that’s exactly 2 recomputes — one when the door slides past the half-open mark while opening, one when it slides back past the same mark while closing. Multiple doors flipping the same frame? The dirty flag de-dupes — at most one recompute per frame regardless of how many doors crossed the threshold. The whole feature is ~20 lines of GX: 10 in the DDA blocker check, 10 in the threshold-flip detector. Cache the predicate, not the result.

Why 50%, not 0% or 100%. Three reasons, all baked in by trial:

  1. Visual/state alignment. The door slab is animating from solid wall → empty doorway. At 50% the slab visually looks halfway in/out, which is the moment a player intuitively expects “now light comes through.” Triggering at 0% (still closed-looking) feels premature; at 100% (already wide open) feels late.
  2. Symmetric open and close. Same threshold both directions means the lightmap pop happens at the visually-equivalent moment of each half-cycle. Different thresholds would make opens and closes feel asymmetric.
  3. One pop per direction. The animation is monotonic, so the timer crosses 0.5 exactly once on opening and once on closing. The was_blocking != now_blocking edge detector can’t fire spuriously — it’s a clean threshold-flip detector by construction.

What “light spills through” actually looks like. When a door opens, the light source on one side of it suddenly sees cells on the other side as reachable via light_blocked_by_wall(). Cells in the previously-dark corridor get an actual brightness contribution from the torch in the opened room, scaled by intensity * (1 - (d/r)²) over distance, just like any other open-air light. Wall cells adjacent to those newly-lit floor cells inherit the brightness via pass-2 propagation, so the corridor walls also brighten on the side facing the open door. The floor renderer (per-pixel sample_light()) and the sprite shader (one sample per sprite) both pick this up the next frame — no special “door light” code anywhere downstream. Closing the door reverses everything: the corridor goes dark, and a soldier standing in it shifts from torch-tinted to AMBIENT-only contribution mid-cycle.

The 0.5 threshold is also a tunable bound on visual/state desync. A player can never see more than ~150 ms of “door is half-visually-open but lightmap still acts closed” (or vice versa), because that’s how long it takes the slab to slide through the threshold neighborhood at DOOR_SPEED = 3.0. Fast enough that the eye reads it as a single instant. Slower door speeds would make the asymmetry visible — you’d see the room brighten before the door finished opening, which would feel wrong without anyone being able to articulate why.

And the iron-bars exception is exactly one line: if (cell_v != 14) in light_blocked_by_wall(). One predicate diff = one new gameplay primitive.


Iron bars: the see-through door

Wolf3D’s classic doors are zero-thickness slabs at the cell midline that slide along a timer. GX’s port handles those the textbook way. But there’s a fifth door type that I’m proud of: iron bars.

Iron bars are a door that:

  • Uses the same red-key gameplay as a locked door
  • Never block light (the alcove behind them shows realistic illumination)
  • The renderer captures bars info per column during cast_ray and continues the DDA past them to find the actual back wall
  • A separate draw_iron_bars_overlay() paints the bars texture after the sprite pass with alpha-cut transparency

Result: from across the level, you can see through to the goal. The V badge sits in an alcove behind iron bars, lit by its own torch, visible from the moment you turn a corner. It telegraphs the objective without breaking the engine.

while (depth < 64) {
    var wall: i32 = get_map(map_x, map_y)
    // 1-6 walls: solid hit, return
    // 10-13 doors: zero-thickness slab at midline
    // 14 iron bars: SEE-THROUGH — record bars info, KEEP STEPPING
    // ...
}

That single behavior — “the DDA doesn’t return on bars, it just records them” — is the entire trick. Five lines in the renderer. The level designer gets a new toy.


The per-pixel floor renderer

The floor in this game is per-pixel lit. Every visible floor pixel reads sample_light() at its world position and modulates fog × base color × per-channel light. That’s 240,000 pixels per frame at the highest quality preset.

const FLOOR_TEX_W = 800
const FLOOR_TEX_H = 300
var g_floor_pixels: u32[240000]   // CPU pixel buffer
var g_floor_tex: Texture          // GPU side

fn draw_floor() {
    for (yy = 0 : FLOOR_TEX_H - 1) {
        var row_dist: f32 = posZ / p_f
        var wx_step: f32 = row_dist * (cos(right) - cos(left)) / SCREEN_W
        for (xx = 0 : SCREEN_W - 1) {
            sample_light(wx, wy)
            var packed: u32 = (u32)rr | ((u32)gg << 8) | ((u32)bb << 16) | ((u32)255 << 24)
            g_floor_pixels[row_base + xx] = packed
            wx += wx_step; wy += wy_step
        }
    }
    UpdateTexture(g_floor_tex, &g_floor_pixels[0])
    DrawTexturePro(g_floor_tex, ...)
}

cosf/sinf are computed once per frame at the FOV edges. World position changes linearly across each row — inner loop is a few adds, a bilinear sample, and a u32 pack. The whole pass takes ~2 ms on medium quality. ~432 MFLOPS. Trivial on any modern machine.

This is the part of the game that says something about what GX is for. There’s no inline assembly. No SIMD intrinsics. No unsafe block. No “performance escape hatch”. It’s plain GX, transpiled to plain C, optimized by clang. The pixel-packing line is required to use (u32) casts because GX won’t silently promote — there’s no chance of a different platform producing different values. The floor manages its own u32[240000] buffer and hands it to the GPU in one UpdateTexture call. No marshalling. No Image copies. No per-frame allocations.


Procedural textures: zero art for walls

Every wall texture is generated at startup from a hash function:

fn gen_texture_brick:Texture() {
    var img: Image = GenImageColor(TEX_SIZE, TEX_SIZE, BLACK)
    for (y in 0..TEX_SIZE-1) {
        for (x in 0..TEX_SIZE-1) {
            var brick_id = (bx / 16) * 13 + row * 7
            var brick_tone = hash(brick_id, 0, 31) % 30
            // mortar lines, bevels, noise
            ImageDrawPixel(&img, x, y, color)
        }
    }
    return LoadTextureFromImage(img)
}

Same approach for stone, metal, wood, mossy, doors (the classic Wolf3D blue panel with steel frame and gold keyhole), iron bars (alpha-cut), and the night sky (a 4096×256 panorama with ~600 procedural stars and a moon).

Deterministic. Same input → same output every run. Screenshots reproduce. Playtesters see identical maps. No art-asset diff noise in source control. The “art pipeline” is six functions in the same file as the game.


AI in 80 lines

Soldiers have a state machine — IDLE, TARGET, SHOOT, RUN, HURT, DYING, DEAD — and a chase strategy that picks one of two modes per frame:

LOS clear?  ──yes──▶ zigzag straight at player (no planner)
            ──no───▶ have we ever seen the player?
                      ──yes──▶ BFS to last-seen, walk first step
                      ──no───▶ drop to IDLE

When LOS is clear, no planner is needed — the visual ray is also the walkable path, plus a sin(zigzag_phase) * 0.6 strafe oscillator to make the soldier harder to shoot. When the player rounds a corner, the soldier remembers (last_x, last_y) and BFS-paths there.

The planner is a textbook breadth-first search over the 32×32 grid:

var g_bfs_visited: i32[1024]
var g_bfs_parent:  i32[1024]
var g_bfs_queue:   i32[1024]

fn bfs_next_step:bool(sx: i32, sy: i32, tx: i32, ty: i32) {
    // ...zero visited, queue start...
    while (head < tail) {
        var cur = g_bfs_queue[head]; head = head + 1
        if (cur == goal) { found = true; break }
        // 4-neighbor expand: filter by pf_passable, mark, enqueue
    }
    // walk parents back, return the cell whose parent IS start
}

Why BFS, not A*? With 1024 cells and uniform cost, the heuristic computation in A* costs more than the expansions it would save. BFS is provably optimal and faster in practice on this grid.

Why return only the first step, not the full path? A single waypoint is one int pair. Storing a full path means tracking (waypoint_index, path_array) per soldier and invalidating it on every door state change. A fresh BFS at 2 Hz is cheaper and never goes stale.

Why does it work? Three globals (g_bfs_visited/parent/queue: i32[1024]) live as zero-allocation scratch. Each soldier replans every 0.5 s; with a dozen enemies on a busy level that’s ~30 BFS calls/sec — a few hundred microseconds total, well under the 16.7 ms frame budget.

There’s also a stuck detector: if a soldier moves less than 0.03 cells over 400 ms, force a fresh plan immediately. Catches the “two soldiers fighting over a corner” deadlock. And the planner triggers enemy_try_open_door() on each waypoint — so the door is already swinging open by the time the soldier gets there. The chase looks fluid.

And — this is the bit I added most recently — enemies and the boss now share the player’s depenetration helper. Soldiers used to occasionally clip a corner during a chase replan and end up half-inside a wall (or half-inside another soldier), which would freeze them mid-stride until the next plan ran. The fix was to lift the per-frame depenetrate-AABB-from-walls routine the player already used and run it on every alive soldier and the boss before their movement integration:

// Before integrating new pos, push the entity out of any wall it
// overlaps toward the nearest passable neighbor.
depenetrate_pos(&g_sol_x[i], &g_sol_y[i], 0.25)   // soldier radius
depenetrate_pos(&g_boss_x, &g_boss_y, 0.45)       // boss footprint

Same predicate as the player’s collision check, same predicate as door_occupied(), same predicate as pf_passable(). One AABB rule, one depenetration helper, four call sites. The “soldier wedged in a doorway” bug class is gone — entities can no longer accumulate invalid overlap states across frames, regardless of how chaotic the combat gets.

The whole AI — perception, planning, movement, door coordination, stuck recovery — is about 80 lines of GX. It plays well at 60 FPS with dozens of enemies because every cost is bounded. The soldier doesn’t think it’s doing pathfinding. It’s consulting two bits of state per frame and walking toward whichever target wins.


C interop: there is no wrapper layer

extern struct Image {
    data: *void
    width: c_int
    height: c_int
    mipmaps: c_int
    format: c_int
}

extern fn UpdateTexture(texture: Texture, pixels: *void)
extern fn DrawTexturePro(texture: Texture, source: Rectangle, dest: Rectangle,
                         origin: Vector2, rotation: f32, tint: Color)

That’s it. That’s the binding. Hundreds of raylib calls per frame. No wrapper layer, no marshalling, no FFI overhead. The GX compiler emits DrawTexturePro(...) as a literal C function call. The binary is byte-equivalent to writing this in C.

raylib’s KeyboardKey is bound as an extern enumIsKeyDown(KEY_W) compiles to the same code C would. Build directives (@link, @cflags, @cfile) live in the modules themselves; the game just writes import "raylib" and the build pipeline handles linker flags, web vs native, the lot.

This is, honestly, the part I find most underrated about GX. You can write C code in a better language without giving up an inch of C’s interop story. The C ABI isn’t bolted on. It is the ABI.


The web port: one flag

The hardest part of porting the game to the browser was deciding what to write in this section, because there isn’t much to say. The CLI flag --target web does it. raylib has a web-friendly fork; GX’s module system selects the right link flags via #if (@os == "web") inside the raylib module:

#if (@os == "web") {
    @cflags("-DPLATFORM_WEB -DGRAPHICS_API_OPENGL_ES2")
    @ldflags("../lib/libraylib_web.a -s USE_GLFW=3 -s ASYNCIFY -s ALLOW_MEMORY_GROWTH=1 ...")
}

The game itself adds one more module-level branch to bundle the data/ folder via emscripten’s preload directive, plus a custom HTML shell with pointer-lock click-to-capture and a “click to resume” overlay. The total game-source web special-casing is a single 5-line #if block clamping a Chrome mouse-delta quirk.

Native build: ~1 MB exe. Web build: ~10.5 MB total (1 MB wasm + 390 KB JS + 9 MB asset bundle), gzipping to ~3 MB on a real CDN. Same source. The native binary literally doesn’t contain the web branches and vice versa, because comptime #if makes the C output not include them.

If you’ve ever wrestled emscripten into shape on a non-trivial codebase, you know the magic word here is “single CLI flag and it works”. That took serious work in the compiler. It pays off forever.


The numbers

MetricValue
Source fileprojects/wolfenstein.gx
Lines~7,000
Native exe (TCC)~1 MB
Web bundle (gzip)~3 MB
Runtime memory native< 20 MB peak
Frame budget at 60 FPS16.7 ms
Floor pass (medium 600×450)~2 ms
Lightmap recompute~30 µs
Dynamic lightmap (5 lights × 50 cells)~2 µs
Native FPS (medium quality)60 (capped)
Web FPS (medium quality)~60 typical

Build times

Here’s where I get to brag, because the numbers are absurd. Clean rebuilds, top-to-bottom — GX frontend (lex, parse, resolve, type-check, codegen) plus the C/IR compiler producing a finished executable:

#BackendC compilerOptBuild timeExe size
1Ctcc (bundled)-O0410 ms39,936 B
2Cclang-O0615 ms160,768 B
3Cclang-O2425 ms160,768 B
4Cclang-O3445 ms160,768 B
5LLVMclang-O0455 ms192,000 B
6LLVMclang-O2565 ms175,104 B
7LLVMclang-O3575 ms175,616 B

That’s not a typo. Sub-second clean rebuilds, even with -O2 and -O3. TCC at 410 ms is what the iteration loop feels like — edit a line, hit run, you’re already playing. Even the heaviest path (LLVM backend → clang → -O3) finishes in ~575 ms.

And the GX frontend itself (everything that’s “the language” — lexing, parsing, resolving, type checking, codegen down to C or LLVM IR) is genuinely fast on the wolfenstein file:

Backendwolfenstein.gx (frontend only)
C frontend (-S)60 ms
LLVM frontend (-S)72 ms

Sixty milliseconds to lex, parse, resolve, type-check, and emit ~10,000 lines of C from a 7,000-line single-file game. The rest of the build time is the C compiler — i.e., not us. I take this as a reassuring signal that the language design isn’t dragging its feet: the work the language does for you is cheap, and the work shipped to a battle-tested C/LLVM toolchain stays where it belongs.

This is the “I open the editor and I’m in flow” payoff. It’s not “fast for a transpile-to-C language.” It’s just fast.

About the GX-vs-C line count

For the same runtime behavior:

MetricGXTranspiled CRatio
Lines7,00510,163C is ~1.45× larger
Non-blank, non-comment5,4729,150C is ~1.67× larger
Bytes279 KB358 KBC is ~1.28× larger

I’m not going to oversell this number, because it’s measuring an auto-generated C file written for maximum debuggability — every if body wrapped in braces, every array access expanded inline as a bounds-checked macro, full Allman-style. A hand-written equivalent in K&R style would land much closer.

The honest takeaway:

For roughly the same line count as carefully written C, GX gives you match, enums, range loops, fat strings, hash maps, automatic bounds checking, and zero headers/forward declarations. Same code volume, more language surface.

That’s the trade. Not “fewer lines.” More language, in the same lines.


What this project says about GX

I started this project to stress-test the language. I finished it convinced of two things.

First: the language quietly held up to every demand the game placed on it. ~7,000 lines in one source file with ~80 functions and ~150 globals, name resolution stayed snappy, type checking stayed snappy, codegen stayed snappy. Forward references work. The 240,000-pixel floor renderer is plain GX code — no inline asm, no SIMD intrinsics, no escape hatch. The pixel-packing math is required to use explicit (u32) casts and the type system caught half a dozen sign-bit bugs at compile time that would have shipped silently in C.

Second: the language stayed out of the way. There’s no “engine layer”. No “framework opinion”. No DSL. Just typed arrays, enums, match, and direct C calls. The game’s discipline (one sample_light() for everyone, one AABB for occupancy and collision, static arrays everywhere, one state-machine pattern) came from me, not from the language imposing it. GX gave me the building blocks and trusted me with them.

That trust is the main thing I want from a language. C trusts you and gives you 1972 tooling. Rust gives you 2026 tooling and trusts you not at all. GX is trying to be the one in the middle: trusts you, but also lets you call match on a u8.


Try it

The game lives in the Showcase section — open gxlang.org/showcase/wolfenstein/ in your browser, then click the canvas to capture the mouse. Controls: WASD to move, Shift to sprint, mouse to look, Esc to release the cursor, 1/2/3 for weapons, V for quick stab, F2/F3/F4 for quality presets.

The source is projects/wolfenstein.gx in the GX repo. The full system-by-system breakdown lives in docs/23_Wolfenstein_Showcase — every decision, every gotcha, every “why this and not that”.

If you’ve been on the fence about whether a transpile-to-C language is “real” enough for graphics work, this is my answer. It’s one file. It’s a real game. It runs fast, builds in seconds, ships to the web from one flag, and the source reads like a language designed by someone who ships things.

The kid who watched that first brick wall scroll by on a beige DOS machine would not believe how fast it builds. DOOM is next. That one’s going to be a longer post.

The rest is gx wolfenstein.gx and an afternoon.