Building a retro 2.5D game engine
Nov 15, 2025
A couple of years ago, I was working on a small university project with the goal of building a simple game and getting it to run on an ESP-32 microcontroller. Since I wanted to create something beyond basic 2D graphics — and microcontrollers don’t typically come with hardware graphics accelerators — I decided to look into the techniques used by early pioneers of 3D graphics, such as Wolfenstein 3D, Doom, and Duke Nukem 3D.
Starting simple - Building a raycasting engine
The easiest way to render something that approximates a 3D scene is by using raycasting. In the simplest implementation, our map is defined as a set of line segments representing the walls. For each vertical line on the screen, we cast a ray from the player’s position, offsetting each ray according to the field of view (FOV) at that screen position, and then test for intersections with the wall segments.
const screenWidthHalf = screenWidth / 2;
const fovHalf = FOV_RADIANS / 2;
const playerView: Vec2 = [
Math.cos(player.cameraAngle),
Math.sin(player.cameraAngle),
];
for (let i = 0; i < screenWidth; i++) {
const rayDir = rotate(
cameraVec,
// [-1, 1] * fovHalf
((i - screenWidthHalf) / screenWidthHalf) * fovHalf,
);
const ray: LineSegment = [
playPosXY,
add(playPosXY, scale(rayDir, MAX_RENDER_DEPTH)),
]; // Ray line segment
let minDistToWall = Number.POSITIVE_INFINITY;
for (const wall of map) {
const intersect = lsIntersect(ray, wall);
if (!intersect) continue; // Skip if no intersection
const distToWall = euclDist(playPosXY, intersect);
if (distToWall <= minDistToWall) {
minDistToWall = distToWall;
}
}
}
Once we know the distance to the closest wall, along with the height of the player’s camera and the wall height, we can easily calculate how much of the player’s vertical FOV is occupied by the wall.
const test = 1;
I also added movement and collision handling at this stage, though I won’t go into detail here. The approach I used was inspired by the techniques employed in the Quake engine.
If you’re interested in learning more, here’s a great video on the topic by Matt’s Ramblings: The code behind Quake's movement tricks explained
Limitations
Using this simple approach, we quickly run into some limitations.
At the moment, our map looks very bare-bones — it’s just a simple maze of walls, all the same height, with no variation in floor elevation.
Additionally, the current implementation requires performing distance checks for every vertical line on the screen against every wall in the map. This approach clearly scales poorly as resolution increases and map complexity grows.
Making things more interesting - Portal rendering
One of the underlying issues with our raycasting engine is that it needs to perform collision checks against every wall in the map to determine distances. However, in most cases, we don’t actually need to render the entire map. If all walls share the same height, the player can only ever see the closest wall, not the ones hidden behind it. Despite this, our current approach still iterates over all walls in the map to find the closest one for each screen column.
The solution we’ll implement is to split the map into smaller sectors, with the constraint that each sector must be a convex polygon—meaning all interior angles are less than 180°, and the polygon “bulges” outward. This property ensures we don’t need to account for walls hidden behind others when rendering a sector.
We then define the walls where two sectors meet as portal walls. When the engine encounters one of these, it knows to continue rendering into the connected sector.
Automating map generation
The first map I created for the portal rendering engine was painstakingly made by first drawing it in GeoGebra and then manually writing it out in the custom map format I had defined for the engine.
When I later revisited the project, I started thinking about how I could add more interesting maps to the engine while minimizing the manual effort. That’s when I came up with the idea of generating a Voronoi diagram, which—given a set of seed points—divides a plane into a series of convex polygons. This was exactly what I needed for my maps.
Here's an example of a Voronoi diagram:
For the map, each sector’s height and floor offset are randomly generated. This results in some interesting visuals:
However, we’re not quite done yet. So far, we’ve successfully created one large room, but that’s not what most games are built around. To make it feel like a proper map, we need to add smaller rooms and connecting pathways.