Skip to content
HN On Hacker News ↗

Catlantean 3D - Making Graphics Like It's 1993

▲ 669 points 109 comments by sklopec 10h ago HN discussion ↗

Pangram verdict · v3.3

We believe that this document is fully human-written

1 %

AI likelihood · overall

Human
100% human-written 0% AI-generated
SEGMENTS · HUMAN 5 of 5
SEGMENTS · AI 0 of 5
WORD COUNT 1,767
PEAK AI % 0% · §2
Analyzed
Jun 9
backend: pangram/v3.3
Segments scanned
5 windows
avg 353 words each
Distribution
100 / 0%
human / AI fraction
Verdict
Human
Pangram v3.3

Article text · 1,767 words · 5 segments analyzed

Human AI-generated
§1 Human · 0%

Catlantean 3D is a side-project I've been slowly building in my spare time for over a year, and I intend to release it on Steam next year.

Your browser does not support the video tag.

My goal was to build a complete, shippable first-person shooter using techniques that were common in the early 90s, while allowing myself the luxury of using a modern compiler and a platform abstraction layer. What this actually means is, the constraints I have foolishly imposed upon myself are as follows:

game must be made entirely from scratch, including the assets all rendering must be done by hand all sound mixing must be done by hand 320x240 target resolution 256 colors only floating point allowed, but behavior must be consistent across platforms decided on fixed point for game logic to guarantee deterministic behavior, floating point for rendering because determinism isn't that important there

must be a finished, polished game that is fun to play (not a tech-demo) platform abstraction layer allowed, but I must pretend it's very limited (within reason): frame buffer to write pixels into keyboard/mouse input audio buffer to write samples into filesystem I/O

no AI slop

If this sounds unreasonable to you, that is because it is. But I'm doing it anyway, and today I'm gonna talk about something that is typically overlooked in development blogs, and that is asset creation. Note: Everything displayed here is work-in-progress, and heavily subject to change. Table of Contents

Palette Rendering VGA Graphics The Palette The Colormap Creating Assets Pre-rendered Sprites Hand-drawn Sprites and Textures Procedurally Generated Sprites and Textures Maps Conclusion

Changelog

2026-05-06 - published.

Palette Rendering VGA Graphics Mode 13h on VGA hardware was the famous 320x200 256-color graphics mode that defined a generation of PC games.

§2 Human · 0%

From a programmer's perspective it was wonderfully simple: you'd have a linear frame buffer where each pixel was represented by a single byte indexing into a palette of 256 colors. If you wanted to draw a pixel, you wrote a byte at a specific address, and that was it, there were no shaders or VRAM, or anything like that. One byte per pixel, and that byte is an index into a palette which contains actual RGB values that would be rendered to screen. This imposes some interesting limitations; when making assets for modern games, you can throw millions of colors at an image, but when your limitation is that every pixel on screen can only be one of 256 colors, asset creation becomes a very different problem because every color choice has to be careful and deliberate.

Games like Doom and Duke Nukem are good examples of this done right. There is a certain crispiness and clarity to these graphics that arises because of these technical limitations, not in spite of them. Restriction forces deliberate choices, and deliberate choices tend to look good. Catlantean 3D is an attempt to reproduce that feeling, but with one caveat - I'm actually going for something closer to VGA Mode-X, which is 320x240. The reason for this is, if you display 320x200 on a 4:3 display, you end up with non-square pixels! While this would be most authentic, I've chosen not to deal with this out of preference rather than objective reason. So how does one create graphics that work within these limits? The Palette Everything begins with 768 bytes, carefully picked through many iterations of trial and error.

The main reasoning for picking these exact colors was the following:

one reserved for transparency (the vibrant pink) one reserved for pure white one reserved for pure black I was obviously going to need a lot of blood, thus reds shades of green and blue because I was going to have red, green and blue keys and color-coded doors game would be set in Catlantis, which is a parody land that resembles ancient Egypt (because cat worship), so obviously, a lot of desert hues (yellows and browns) lots of grays

§3 Human · 0%

because the setting involves many technical installations (Catlantis is under occupation by cybernetic dog-men) some beige hues to break up monotony over grays, and to serve as warmer replacements when darkening (more on this later) the rest would be filled as necessary when creating textures - highly subjective and impossible to explain, other than "it looked right"

The palette did not spring into life all at once; it involved a lot of back-and-forth during asset creation, testing, and re-iterating in general. Below are some examples of sprites and textures from the actual game:

The Colormap Catlantean 3D is a traditional raycaster. The map consists of tiles which are all identical in size; some are walls, others are just voids with a floor and ceiling. In order to render the map, the renderer uses the DDA algorithm for each column of screen, traversing the tilemap and determining where it hits the map geometry, and based on this, a wall column is rendered on screen with the appropriate texture, sampled from appropriate coordinates. Floors and ceilings are rendered after as horizontal scanlines, filling in the rest of the screen. Raycasting has been done to death by other blogs and websites, so I'm not going to cover all of it, but I do want to cover what I think is its most overlooked aspect: lighting. If we were to render the game world using just the palette, without any special effects, we would end up with something that looked rather flat and unimpressive:

But what we wanted was the following. Notice how the light diminishes the further away geometry is from player, and how one side of the map tiles is just slightly darker than the other. This gives an impression of depth.

With a modern hardware-accelerated renderer, this would be trivially done in a shader - based on how far the vertex is, we would multiply its color vector by a floating point factor and get a diminished color vector as a result. But how do we achieve something like this with a palette renderer? It has no concept of color, just indices into palette. So if we wanted to find a darker shade of a certain color, we would need to loop through the palette and find the color that meets our criteria of "darker".

§4 Human · 0%

This is just too much because we can't loop through the entire palette for every pixel we render onto the screen, it would be too slow. What we could do instead was some preprocessing, to allow a fast color lookup based on distance at runtime. If we were to lay out our palette into a single row like this...

We then choose the number of shade levels (32 in my case) meaning each color needs 31 darker variants, all sourced from the palette. We know each color's RGB values, so from this, and the shade index we can determine the closest target color of that shade: // First shade index (0) is original color. float darkening_factor = (32 - shade_index) / 32.0f; target_darker_color.r = current_color.r * darkening_factor; target_darker_color.g = current_color.g * darkening_factor; target_darker_color.b = current_color.b * darkening_factor;

But that color might not exist in the palette. So we need to loop through the palette and find the closest color to it. Definition of "close" actually changed for me during development - at first, I just took euclidean distance as a measure, but the problem with that was that almost everything had a tendency to gravitate towards the greys, simply due to the mathematics. Some older games actually did use Euclidean distance, but to me this didn't look very good. I can't explain why exactly, but a lot of darker shades appeared somewhat cold and lifeless. So instead, I converted my colors to Oklab color space, and leveraged its perceptual distance formula, which is closer to how humans perceive color differences. I also apply a small shift towards warmer hues the darker the color is (a common concept in pixel art called "hue shifting"). This is typically not necessary, but it does make the game look just a bit better. How do I define "better" in this case? I have no idea, it just looks right. Frustrating, isn't it? It's hard to rationalize something subjective. Back to our algorithm... Essentially, for each color, we create a column that represents the shades of that color. What we end up with is a 2D matrix of palette indices called the colormap.

§5 Human · 0%

Note that the colormap gradients are imperfect, because we're still restricted to colors from the palette:

So now, determining a darker shade of color N based on distance becomes trivial. Given colormap row index (i.e. shade level) based on distance: colormap_row = 32 * fragment_distance / view_distance

We pick N-th entry in row belonging to that shade - that is the palette index of the darkened color N. And voila, O(1).

Also, instead of calculating the colormap row index for every pixel, the cost is further reduced by performing calculation:

only once per screen column when rendering walls, because they're perfectly vertical, so every pixel in column has same distance from camera only once per screen row when rendering floors, because they're perfectly horizontal, so every pixel in row has same distance from camera only once per sprite because they are perfectly flat billboards where every pixel has the same distance from camera

So we're doing colormap row index calculation 320 times for walls, at most 240 times for floors, and once per visible sprite (raycasting gives free occlusion culling). That is cheap, and the payoff is great. Doom and many other titles used similar approaches.

Creating Assets Textures and sprites in Catlantean 3D fall into three categories:

Pre-rendered sprites - 3D models created in Blender and rendered to textures Hand-drawn sprites and textures Procedurally generated textures - generated via special Python scripts by combining hand-drawn art

Pre-rendered Sprites I am working a full-time job and have a decently active life, so my time to work on the game is limited. Thus, I wanted to minimize the time I spend reiterating when making complex sprites that involve animations. I rarely get something right on the first attempt, so naturally, reiteration is expected, and it is hard to reiterate when you need to make changes to many frames of an animation. The more efficient approach was to create sprites in Blender as 3D models, rig and animate them there, and then render them to a series of textures with special Python scripts that leverage Blender's Python API. Reiteration then involved making changes in the model, and the rendering scripts did the rest, which was a lot of time saved.