Building a 2.5D Raycaster on a 2D Engine
Wolfenstein-style dungeon crawling in Go. Pixel buffers, DrawTriangles, Kage shaders, and textured floors. How a retro rendering trick pushed Willow past its comfort zone.

Ebitengine is a 2D game framework. Willow is a 2D scene graph built on top of it. So why am I building a first-person dungeon crawler with them?
Because 2.5D raycasting is not really 3D. It is a rendering trick from the early 1990s. The level is a flat 2D grid. The camera casts rays across that grid, one per screen column, and scales the wall height by distance to create the illusion of depth. No vertex transforms, no projection matrices, no z-buffer. Just clever math on a flat map. That is what made Wolfenstein 3D run on a 386.
I grew up playing those games. Wolfenstein, Doom, Eye of the Beholder, Dungeon Master. The corridor-crawling, the sense of being inside something, the way darkness swallowed the far end of a hallway. When I started building demos for Willow, I kept coming back to this. Not because Willow needs a 3D renderer, but because those old games are burned into my brain, and I wanted to see if the engine could pull it off.
It could. And then some. What started as a proof of concept turned into three progressively more ambitious demos, each one pushing the platform further.
The classic way: pixel buffers

The first version did it the way id Software did it. Cast a ray for every column on screen, march through the grid with DDA (Digital Differential Analyzer) until you hit a wall, calculate the perpendicular distance (to avoid fisheye distortion), and draw a vertical line of colored pixels. No textures. No GPU. Just CPU math and a flat byte array.
The pixel buffer gets uploaded to an ebiten.Image every frame with WritePixels, and a Willow sprite displays it fullscreen. Everything else in the scene, the weapon, the crosshair, the health and ammo HUD, the damage flash, those are all regular Willow nodes sitting on top. The raycaster owns the pixel work, Willow owns the game layer.
It works. You walk around a dungeon with colored walls, face-shaded so north/south faces are brighter than east/west. Distance fog darkens everything toward the far end of the hallway. It feels like the Windows 95 Maze screensaver, which honestly was the exact vibe I was going for at first. Nostalgia is a strong motivator.
But pixel-buffer rendering means uploading 800 x 600 x 4 bytes to the GPU every single frame. That is almost two megabytes of bus traffic per frame for a viewport that could be described as a few hundred quads. I wanted to see if I could do better.
Taking it further: DrawTriangles
The second version throws away the pixel buffer entirely. Instead of writing pixels on the CPU and uploading the result, it builds screen-space geometry. Each wall column becomes a quad (two triangles), colored with vertex RGB, and the entire viewport gets submitted in a single DrawTriangles call through a Willow mesh node.
Zero pixel-buffer uploads. The CPU still does the raycasting to figure out where walls are and how tall they should be. But instead of filling pixels, it fills vertex buffers. The GPU does the actual rendering. Ceiling and floor are gradient mesh quads with vertex colors. The whole 3D viewport is pure GPU geometry.
Ebitengine exposes DrawTriangles (and its 32-bit variant) as a low-level escape hatch for custom rendering. Most people use it for particle systems or distortion effects. I wanted to see what happens when you push 800 quads through it every frame, rebuilding the geometry from scratch each tick. Turns out, it handles it just fine.
The fog problem
Flat-colored walls with no depth cues look terrible. In the pixel-buffer version, I could darken each pixel on the CPU based on ray distance. Easy. But with DrawTriangles, the wall colors live in vertex data. If I darken the vertex colors directly, I lose the original wall color and everything becomes a muddy mess at medium distances.
The solution was a Kage shader. Ebitengine's shader language lets you write per-pixel fragment programs that run on the GPU. I encode the wall distance into the vertex alpha channel (1.0 for close, near-zero for far), and the shader un-premultiplies to recover the original color, then blends toward black based on the encoded distance.
Applied as a CustomShaderFilter on the Willow mesh node, this gives you smooth distance fog that darkens hallways naturally. Close walls are bright and fully colored. Far walls dissolve into blackness. The alpha trick is a bit of a hack, but it works perfectly because the fog shader runs before the result hits the screen. Nobody sees the intermediate premultiplied values.
Adding textures
Colored walls look retro, but textured walls look like a game. The third version adds a tileset atlas. Instead of solid-color quads, each wall column samples from a sprite sheet. The DDA raycaster still figures out which wall was hit and where. But now it also calculates the exact horizontal position on the wall face, maps that to a pixel column in the tileset, and sets the source UVs on the quad accordingly.
The fog shader still applies, using the same alpha-encoded distance trick. But now it is darkening actual stone and brick textures instead of flat colors. Walls have detail. They have character.
Floors and ceilings on the GPU
Textured walls are half the picture. The floor and ceiling were still gradient-colored quads from the previous version. To complete the look, the textured variant adds a second Kage shader that runs on a fullscreen quad and does per-pixel floor/ceiling raycasting entirely on the GPU.
For each pixel below (or above) the horizon, the shader computes the horizontal distance to the floor plane at that scanline, interpolates the ray direction across the screen width, and samples the tileset at the resulting world-space coordinate. Distance fog applies here too, so floors and ceilings darken in sync with the walls. It all renders in a single pass.
Why 2.5D on a 2D engine?
This is the elephant in the room. Ebitengine and Willow are 2D. They are designed for 2D. If you want actual 3D, there are engines built for that. So why do this?
First, the level is a flat grid. The math is trigonometry on a plane. The output is a set of scaled vertical strips or, in the GPU versions, a set of screen-space quads. Nothing about this requires a 3D pipeline. The term "raycasting" here refers to the retro technique from the early 90s, not the modern GPU ray tracing used in path tracers and RTX rendering. They share a name but have almost nothing else in common.
Second, it is a stress test. If a 2D scene graph can handle hundreds of dynamically rebuilt quads per frame with custom shaders and per-pixel floor casting, then the simpler things (sprites, tilemaps, UI, particles) are going to be rock solid. Pushing the platform past its comfort zone is how you find its real limits.
Third, it is just fun. Walking through a dungeon you built yourself, watching the fog roll in at the end of a corridor. Games are supposed to be fun to make. This was fun to make.
Could this become a game engine?
Maybe. The pieces are all there. DDA raycasting for walls. GPU shaders for floors and ceilings. Tileset atlas rendering. JSON-based level loading. Collision detection. Willow handles the entire game layer on top: HUD, menus, tweened animations, input. You could build a dungeon crawler, a retro FPS, or a Wizardry-style RPG on this foundation without writing a single line of low-level graphics code.
For now it lives as a set of tests and examples. If I ever want to revisit those old-school corridor crawlers, the scaffolding is ready. The raycaster handles the rendering, Willow handles everything else, and the two stay cleanly separated. That is probably the most important takeaway: even when you are doing something unusual with a framework, a good architecture lets you keep the weird parts isolated and the normal parts normal.