· 5 min read
Building an Image-to-Minecraft Converter
How I built an API that converts images into Minecraft map art using Node.js and Sharp.
Building an Image-to-Minecraft Converter
I built an API that takes a regular image and spits out a Minecraft .dat file you can use as map art in-game. The idea is you upload a photo, and the service converts it into something you can display on an in-game map. Sounds simple enough, but there were a few interesting problems to solve along the way.
The Colour Matching Problem
Minecraft maps don’t display arbitrary colours - they use a fixed palette. Depending on the game version, you get somewhere between 50-60 distinct colours to work with. A typical photograph has millions of unique colour values. The core problem is: how do you map each pixel from the source image to the nearest available colour in Minecraft’s palette?
This is colour quantization, and it’s more nuanced than it first appears. The naive approach is to calculate the Euclidean distance in RGB space between your source colour and each palette colour, then pick the closest. That works, but RGB distance doesn’t map well to how humans perceive colour difference. Two colours might be mathematically close in RGB but look obviously different to the eye, or vice versa.
I ended up using a weighted distance formula that accounts for how human vision works - we’re more sensitive to green, less to blue. The weights aren’t arbitrary; they come from how rod and cone cells in the eye respond to different wavelengths. The formula looks something like:
It’s not perfect, but the results are noticeably better than raw RGB distance.
The other challenge was that Minecraft’s palette changes between versions. I had to build a lookup system that could handle different palette sets, so the same image would render correctly whether you were playing 1.12 or 1.19.
Once you’ve mapped every pixel to a palette index, you need to serialize the result into Minecraft’s .dat format. This is NBT (Named Binary Tag) data compressed with gzip. Getting the byte structure right took some trial and error - Minecraft is picky about malformed data and will just silently fail to load the map.
Sharp for Image Processing
Sharp handled all the image input and pixel manipulation. It’s built on libvips, so it’s significantly faster than pure JavaScript alternatives like Jimp. For an API that might process large images, performance matters.
The workflow: load the image with Sharp, resize it to map dimensions (128x128 pixels for a single Minecraft map), extract the raw pixel buffer, loop through each pixel running the quantization, then hand off the result to the NBT serialization step.
Sharp’s raw() output gives you a flat buffer of RGB or RGBA values. Extracting individual pixels is just index math:
From there, you pull out the R, G, B values and run them through the matching algorithm.
One thing to watch: Sharp is async by default, which is usually what you want for an API. But some operations can be chained more efficiently than others. I spent some time profiling to figure out where I was creating unnecessary intermediate buffers.
Fastify Instead of Express
I’d been writing Express APIs for years and wanted to try something different. Fastify promised better performance and a more structured approach.
The schema-based validation was the biggest adjustment. In Express, you typically validate request bodies manually or with middleware like Joi. Fastify wants you to define JSON schemas for your routes, and it validates automatically. Once I got used to writing the schemas, it actually made the API clearer - the schema is documentation and validation in one place.
The plugin system took some learning too. Fastify uses encapsulation heavily - plugins can’t see each other’s registrations unless you explicitly share them. This is good for isolation but means you have to think about how to structure shared dependencies like database connections.
One genuine issue I hit: streaming binary responses (the zip files containing the converted maps) didn’t work out of the box the way I expected. The middleware assumed text responses in some places. I had to dig into how Fastify handles reply serialization and set the content type headers explicitly before streaming. Not hard once I understood it, but the documentation wasn’t clear.
Domain-Driven Design (Probably Overkill)
I used this project to practice Domain-Driven Design patterns. The service got divided into distinct layers: domain (the colour matching logic, map representation), application (use cases like “convert image”), infrastructure (Sharp integration, file handling), and interface (HTTP routes).
For a service this simple, it’s more structure than necessary. You end up with a lot of files and indirection for what’s essentially one main operation. But the patterns are useful to practice somewhere low-stakes before applying them in production code.
The separation did make testing easier. I could unit test the colour quantization logic without touching Sharp or HTTP concerns. And when I wanted to add a second output format (generating a schematic file for building the map in-game), the domain logic didn’t need to change at all.
Storing Results
The converted files get uploaded to Cloudflare R2 (their S3-compatible object storage). Users get a download link that expires after a set time. This keeps the service stateless - I don’t have to manage file storage on the server or clean up old conversions.
R2’s API is compatible enough with S3 that the AWS SDK just works. The main difference is pricing - R2 doesn’t charge for egress, which matters when you’re serving downloads.
What I Got Out of It
The colour science was the genuinely interesting part. I went in thinking colour matching was a solved problem and came out with a better understanding of colour spaces, human perception, and why sRGB isn’t as straightforward as it looks.
The architectural experimentation - Fastify, DDD patterns - was useful practice even if the project didn’t strictly need it. Better to learn the rough edges on a side project than in production code with deadlines.