MapForge: Building a Browser-Based Minecraft Map Art Generator
MapForge: Building a Browser-Based Minecraft Map Art Generator
TL;DR: MapForge converts any image into a Minecraft map art layout — fully in-browser, no server, no install. It quantizes pixels to the Minecraft block palette, supports Atkinson/Blue Noise dithering, exports
.schemand.litematicfiles, and has an inline crop workspace. Built with SvelteKit + Web Workers.
Live: mf.yuzhes.com · Source: bkmashiro/mapforge
What Is Map Art?
In Minecraft, a filled map item renders a 128×128 pixel view of the ground below. By carefully placing colored blocks at specific coordinates, you can create pixel art that appears on a map when carried by a player. It’s an old trick, but the preparation — converting an arbitrary image into a valid block layout — is tedious to do by hand.
MapForge automates that conversion entirely in the browser.
Architecture
The core pipeline is:
Image → Crop → Preprocess → Color Quantize → Dither → Preview → Export
Everything runs client-side. No data leaves the browser.
Web Worker for Heavy Lifting
Color quantization on a 128×128 image against a palette of ~200 block tones is CPU-bound. Running it on the main thread would freeze the UI during the ~1-2 second conversion. The solution is obvious: move it to a Web Worker.
// +page.svelte
worker = new Worker(
new URL('$lib/workers/converter.worker.ts', import.meta.url),
{ type: 'module' }
);
worker.postMessage({ type: 'convert', imageData, width, height, options, coloursJSON });
worker.onmessage = (e) => {
if (e.data.type === 'progress') { progress = e.data.progress; return; }
resultPixels = e.data.pixels;
// ...
};
The worker receives raw ImageData, builds a color palette from the Minecraft block data, runs quantization row by row (posting progress updates), and returns the quantized pixel matrix plus material counts.
Color Matching: LAB vs RGB
RGB Euclidean distance is fast but perceptually inconsistent — colors that look similar to humans can have large RGB distances (and vice versa). MapForge defaults to CIE LAB space with configurable weights for L, A, B channels.
function deltaLab(a: LabColor, b: LabColor, weights: LabWeights): number {
const dl = (a.L - b.L) * weights.L;
const da = (a.a - b.a) * weights.a;
const db = (a.b - b.b) * weights.b;
return dl * dl + da * da + db * db;
}
For each input pixel, the nearest palette entry is found by brute-force nearest-neighbor search (the palette is small enough — ~200 entries — that a KD-tree would be overkill).
Dithering
Three modes are implemented:
Atkinson dithering (default) — diffuses 75% of the quantization error to 6 neighboring pixels. Produces sharp, high-contrast results typical of classic Mac OS graphics. The partial diffusion (vs Floyd-Steinberg’s 100%) intentionally discards some error, keeping dark areas darker.
Blue Noise dithering — uses an IGN (Interleaved Gradient Noise) 64×64 threshold matrix embedded directly in the worker. No file load, no async. Each pixel’s quantization error threshold is looked up from the matrix, producing structured noise that’s more visually pleasant than random dithering at the cost of some accuracy.
None — direct nearest-neighbor quantization. Fastest, but visible banding on gradients.
The Inline Crop Workspace
Early versions had a separate upload flow followed by a static preview. That was clunky. The current design embeds an interactive crop workspace directly on the main page: you upload an image, and immediately see it in a pannable/zoomable canvas with a fixed-size selection box representing the map grid.
The crop workspace is split into two components:
InlineCropWorkspace.svelte— handles pan, zoom (scroll wheel), and selection drag via pointer events. EmitsselectionChangewhenever the view or selection moves.InlineWorkspace.svelte— owns the actual image element, receives selection events, debounces crop extraction, and dispatches the croppedImageDataupward.
function extractCroppedImageData(source: HTMLImageElement, rect: SelectionRect): ImageData {
const width = mapWidth * 128;
const height = mapHeight * 128;
// fill background color first (for out-of-bounds areas)
context.fillStyle = bgColor;
context.fillRect(0, 0, width, height);
// draw image region scaled to target size
context.drawImage(source, rect.x, rect.y, rect.width, rect.height, 0, 0, width, height);
return context.getImageData(0, 0, width, height);
}
When the selection extends outside the image (e.g., zoomed in too far), the out-of-bounds area is filled with the user-configured background color (default: white, corresponding to White Wool in Minecraft).
Svelte 4 Reactivity Bugs I Hit
This section is the honest part of the post.
Bug 1: Reactive Block Reading a Variable Written by a Called Function
The render pipeline is driven by a reactive signature:
$: renderSignature = JSON.stringify({ mapWidth, mapHeight, ditherMethod, /* ... */ });
$: if (renderSignature && hasEnabledColours) {
worker?.terminate(); // ← reads `worker`
worker = null; // ← writes `worker`
isRenderQueued = true;
// schedule convert() after debounce
}
And convert() does:
worker = new Worker(...); // ← writes `worker`
The bug: worker is read inside the reactive block (worker?.terminate()). When convert() writes worker = new Worker(...), Svelte detects the change and re-runs the reactive block. The block terminates the new worker, resets isRenderQueued, and reschedules the debounce. This happens infinitely.
Fix: Remove all worker reads/writes from the reactive block. Worker lifecycle management belongs entirely inside convert().
Bug 2: Reactive Block Re-triggering on Its Own Side Effects
Even after Bug 1 was fixed, the Rendering… indicator kept flickering. The root cause: the reactive block assigned isRenderQueued = true. In Svelte 4, assignments inside reactive blocks are tracked, and any downstream reactive statement that reads those variables (like $: isRendering = isRenderQueued || isConverting) gets re-scheduled. In practice, this caused the entire reactive update cycle to re-run the block.
Fix: Replace the reactive block with a plain function call:
let _lastScheduledSig = '';
function scheduleConvertIfChanged(sig: string, hasColours: boolean) {
if (!sig || !hasColours) { /* reset */ return; }
if (sig === _lastScheduledSig) return; // no-op if unchanged
_lastScheduledSig = sig;
isRenderQueued = true;
// debounce → convert()
}
$: scheduleConvertIfChanged(renderSignature, hasEnabledColours);
The manual _lastScheduledSig guard prevents re-execution even if Svelte re-calls the function due to unrelated state changes.
Lesson: In Svelte 4, $: if (x) { sideEffects() } is fragile when sideEffects() mutates reactive state. The $: fn(dep) pattern with an explicit guard inside fn is safer.
Bug 3: renderSignature Didn’t Track Crop Content
After fixing the loop, moving the selection box didn’t re-trigger a render. renderSignature included croppedImageData.width and croppedImageData.height — but these are always mapWidth * 128 and mapHeight * 128. Moving the selection produces a new ImageData with identical dimensions.
Fix: Add a cropGeneration counter incremented in the onCrop handler, and include it in renderSignature. Simple, zero-overhead.
Export: .schem and .litematic
MapForge exports in two formats:
.schematic (MCEdit/WorldEdit) — NBT-encoded, stores block data in a flat Blocks byte array with a separate Data nibble array. Simple format, widely supported.
.litematic (Litematica) — more complex. Uses a packed bit array with variable bits-per-block (⌈log₂(palette size)⌉), stores region metadata, block entity data, and tile entities. The format is documented by reverse-engineering the mod source.
Both are generated entirely in the browser using a hand-rolled NBT encoder. No WASM, no native code — just typed arrays and bit manipulation.
Deployment
SvelteKit with adapter-cloudflare. The entire app compiles to a single Worker script + static assets deployed on Cloudflare Pages. Build time is ~2s; cold start is effectively zero since CF Workers run at the edge.
One thing to note: adapter-cloudflare produces output in .svelte-kit/cloudflare/, not dist/. If you’re configuring CF Pages manually, the output directory matters.
What’s Next
- Staircase mode — for maps that need to leverage the Minecraft shading system (north/south slope brightening). This is already partially implemented.
- Litematica region merging — currently exports one region per map tile; merging into a single region would make placing easier.
- Color set presets — “Survival Easy” (no rare blocks) is already there; adding “Java 1.20” vs “Bedrock” palette variants would help accuracy.
If you use MapForge and hit issues, open an issue on GitHub.
Built in a weekend. Debugged for two more.
Recent Additions (March 2026)
Rotation Support
The most requested feature was the ability to rotate the source image before cropping. Adding ↺ and ↻ buttons was trivial — the interesting part was making rotation actually work correctly end-to-end.
My first attempt applied CSS transform: rotate(Ndeg) to the preview image element and called it done. It looked right in the preview. But the exported tiles were wrong: the CSS transform is purely visual; the underlying ImageData extraction still read from the unrotated image. The top crop preview and the bottom tile grid were out of sync.
The correct approach: instead of CSS post-processing, render the image to a temporary canvas with the same transforms applied programmatically, then extract ImageData from that canvas.
function extractWithRotation(
source: HTMLImageElement,
rect: SelectionRect,
rotation: 0 | 90 | 180 | 270
): ImageData {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
const w = targetWidth, h = targetHeight;
canvas.width = w;
canvas.height = h;
ctx.save();
ctx.translate(w / 2, h / 2);
ctx.rotate((rotation * Math.PI) / 180);
ctx.translate(-w / 2, -h / 2);
ctx.drawImage(source, rect.x, rect.y, rect.width, rect.height, 0, 0, w, h);
ctx.restore();
return ctx.getImageData(0, 0, w, h);
}
By rendering to a canvas with the same translation/rotation as the CSS preview, the extracted ImageData always matches what the user sees. The CSS transform on the preview element was removed entirely — the canvas now drives both the preview and the export, keeping them in sync.
Block Chinese Localization
The materials list previously showed only minecraft:block_id. For most players — especially Chinese-speaking ones — that’s not particularly readable. I wanted to show the block’s proper Chinese name alongside the namespace ID.
Minecraft ships a zh_cn.json translation file as part of the game assets. I fetched the 1.20 version, which contains ~9000 translation keys. The block entries follow the pattern block.minecraft.<id>. I wrote a small script to cross-reference the block list used by MapForge against this JSON file.
Result: 323 out of 324 blocks matched (one block ID had a discrepancy between MapForge’s internal name and the translation key format). The match data was baked into a generated blockMeta.ts file:
export const blockMeta: Record<string, { zhName: string }> = {
"minecraft:stone": { zhName: "石头" },
"minecraft:grass_block": { zhName: "草方块" },
// ... 321 more
};
The materials list now renders as:
草方块 (minecraft:grass_block) × 42
石头 (minecraft:stone) × 17
The English name was already available from the block color data, so this is additive — both are shown, with the Chinese name first when the locale is zh-CN.
i18n Completion
MapForge had partial i18n from the start (English and Chinese locale files), but a number of UI strings were still hardcoded in component templates. In this update, 16 translation keys were added:
imageWorkspace— section label for the crop workspacepreviewSection— label for the output preview areawaitingRender— placeholder text shown before first renderbgColor— label for the background color pickerrotation— label for the rotation control group- …and 11 more covering tooltips, button labels, and status messages
All UI strings are now routed through the i18n system. Switching locale at runtime updates the full interface without a page reload.
README Overhaul
The README was a wall of text. I rewrote it with:
- Before/after demo table — side-by-side screenshot comparison showing input image vs rendered map art vs in-game map view
- for-the-badge shields — build status, license, npm (not applicable here, but Cloudflare deployment status), and stack badges
- Chinese README (
README.zh.md) — full translation of the README, covering setup, features, and technical notes
The Chinese README follows the same structure as the English one and is kept in sync manually for now. If the README evolves significantly, a translation CI step might be worth adding.