Extracting Game Board Information from a Point Map Using Jimp
Using Jimp, the author scans a designer‑supplied point‑map image—where each 1 px marker’s color encodes a board square type—to automatically extract (x, y) coordinates, merge adjacent pixels via region‑growing for visibility, output a JSON layout, and drive front‑end placement of Monopoly‑style tiles.
For front‑end developers, aligning UI with design mockups is essential, but manual adjustment becomes impractical when the number of assets is large.
In a recent activity the author needed to implement a Monopoly‑style board where many different squares must be placed accurately. Manually positioning each square would be too labor‑intensive, so a “point map” approach was adopted.
Point map definition : a special image supplied by designers that satisfies three conditions: (1) a 1 px pixel placed at the top‑left corner of each square, with different colors representing different square types; (2) a solid background color to separate background from squares; (3) the same dimensions as the background map.
By scanning the point map with the jimp library, the script extracts the (x, y) coordinates and the color (used as the square type) of every non‑background pixel and writes them to a JSON file.
const JImp = require('jimp');
const nodepath = require('path');
function parseImg(filename)
{
JImp.read(filename, (err, image) => {
const { width, height } = image.bitmap;
const result = [];
// background pixel color (top‑left corner)
const mask = image.getPixelColor(0, 0);
// collect non‑mask pixels
for (let y = 0; y < height; ++y) {
for (let x = 0; x < width; ++x) {
const color = image.getPixelColor(x, y);
if (mask !== color) {
result.push({
// x y coordinates
x,
y,
// square type
type: color.toString(16).slice(0, -2),
});
}
}
}
// output
console.log(JSON.stringify({
// path
path: result,
}));
});
}
parseImg('bitmap.png');The generated JSON looks like the following (only a fragment is shown):
{
"path": [
{
"type": "",
"x": 0,
"y": 0,
},
// ...
],
}After obtaining the coordinates, a path‑connection algorithm is required to link the points into a continuous route. The author provides a recursive function that selects the nearest candidates and searches for the shortest total distance.
function takePath(point, points)
{
const candidate = (() => {
// sort by distance ascending
const pp = [...points].filter(i => i !== point);
const [one, two] = pp.sort((a, b) => measureLen(point, a) - measureLen(point, b));
if (!one) {
return [];
}
// if two points are close enough, enumerate both routes and keep the shorter one
if (two && measureLen(one, two) < 20000) {
return [one, two];
}
return [one];
})();
let min = Infinity;
let minPath = [];
for (let i = 0; i < candidate.length; ++i) {
// recursively find the minimal path
const subpath = takePath(candidate[i], removeItem(points, candidate[i]));
const path = [].concat(point, subpath);
// total distance of the path
const distance = measurePathDistance(path);
if (distance < min) {
min = distance;
minPath = subpath;
}
}
return [].concat(point, minPath);
}The basic solution works but has two drawbacks: the 1 px markers are hard to see, and the point map carries only the type information.
To address the visibility issue, the markers can be expanded into a larger region, and a region‑growing algorithm is used to merge adjacent pixels of the same color. The implementation below includes helper functions for color comparison, boundary checking, selecting the dominant color, and merging regions.
const JImp = require('jimp');
let image = null;
let maskColor = null;
// determine whether two colors differ enough (tolerates minor image noise)
const isDifferentColor = (color1, color2) => Math.abs(color1 - color2) > 0xf000ff;
// check whether a coordinate is inside the image
const isWithinImage = ({ x, y }) => x >= 0 && x < image.width && y >= 0 && y < image.height;
// select the color that appears most frequently in a region
const selectMostColor = (dotColors) => { /* ... */ };
// pick the top‑leftmost coordinate of a region
const selectTopLeftDot = (reginDots) => { /* ... */ };
// region merging (similar to image‑segmentation region growing)
const reginMerge = ({ x, y }) => {
const color = image.getPixelColor(x, y);
const reginDots = [{ x, y, color }];
const dotColors = {};
dotColors[color] = 1;
for (let i = 0; i < reginDots.length; i++) {
const { x, y, color } = reginDots[i];
const seeds = (() => {
const candinates = [/* left, right, up, down, left‑up, left‑down, right‑up, right‑down */];
return candinates
.filter(isWithinImage)
.map(({ x, y }) => ({ x, y, color: image.getPixelColor(x, y) }))
.filter(item => isDifferentColor(item.color, maskColor));
})();
for (const seed of seeds) {
const { x: seedX, y: seedY, color: seedColor } = seed;
reginDots.push(seed);
image.setPixelColor(maskColor, seedX, seedY);
if (dotColors[seedColor]) {
dotColors[seedColor] += 1;
} else {
dotColors[seedColor] = 1;
}
}
}
const targetColor = selectMostColor(dotColors);
const topLeftDot = selectTopLeftDot(reginDots);
return {
...topLeftDot,
color: targetColor,
};
};
const parseBitmap = (filename) => {
JImp.read(filename, (err, img) => {
const result = [];
const { width, height } = image.bitmap;
maskColor = image.getPixelColor(0, 0);
image = img;
for (let y = 0; y < height; ++y) {
for (let x = 0; x < width; ++x) {
const color = image.getPixelColor(x, y);
if (isDifferentColor(color, maskColor)) {
result.push(reginMerge({ x, y }));
}
}
}
});
};Beyond merging, the author suggests encoding additional information in the RGBA channels (e.g., r = type, g = width, b = height, a = order) or even using each digit to represent a distinct attribute.
In summary, the workflow is:
Designers provide the point map that meets the three requirements.
Jimp scans the image and produces a JSON file containing each square’s coordinates and type.
The front‑end reads the JSON and places the corresponding assets; the author used Pixi.js for rendering.
The FAQ clarifies that square size can be used as width/height only when squares do not overlap, explains how to handle lossy images by adjusting the color‑difference threshold, and notes that the threshold value (e.g., 0xf000ff) is chosen arbitrarily based on the color distribution.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
