How a grid of colors organizes itself


A grid starts with random colors. Repeatedly show it another random color, find the closest match, and move that cell and its neighbors a little toward the sample. Over time, local patches of similar colors emerge without explicitly sorting the grid.

This is a small visualization inspired by Kohonen’s self-organizing map, often called a SOM. Press Start to train the grid and Stop to pause. Reset creates a fresh random grid and leaves training paused.

Loading...

How it works

Each cell holds an RGB color, a vector of red, green, and blue values from 0 to 255. The grid has 10 columns and 10 rows.

  1. Generate a random RGB sample.
  2. Find the cell with the smallest Euclidean distance to that sample in RGB space. This is the best-matching cell.
  3. Blend the sample into that cell and the surrounding eight cells, skipping neighbors beyond the grid’s edges.
  4. Repeat with another sample.

Updating neighbors is the important part. If only the best match changed, nearby cells would have no reason to resemble one another. A shared update encourages nearby cells to represent similar colors.

The demo keeps the original color-mixing choice: it measures similarity in RGB but blends in CMYK, the cyan, magenta, yellow, and black color model. For each CMYK channel, the update is new = (old + 0.1 * sample) / 1.1. Equivalently, it moves about 9.1% of the way from the old channel value to the sample, then converts back to RGB.

This is a SOM-inspired toy, not a full textbook training schedule. It keeps a fixed neighborhood and learning rate rather than gradually shrinking them. The grid keeps adapting instead of reaching a guaranteed final arrangement. RGB distance also does not perfectly match human perception.

The actual update code

These are the functions used by the live demo. Phaser handles the canvas and animation loop. Standard HTML buttons start, pause, and reset the grid. cells[column][row] stores each cell’s coordinates and color, while game.time.now supplies the elapsed time in milliseconds. Updates are paused by isGameStarted and limited to at most one per animation frame.

function update() {
    if (!isGameStarted || game.time.now - timeCheck <= 5) {
        return;
    }
    var randomColor = getRandomRgb();
    var nearestCell = cells[0][0];
    var shortestDistance = Infinity;
    for (var x = 0; x < cellNum; x++) {
        for (var y = 0; y < cellNum; y++) {
            var distance = colorDistance(randomColor, cells[x][y].color);
            if (distance <= shortestDistance) {
                shortestDistance = distance;
                nearestCell = cells[x][y];
            }
        }
    }
    changeColor(nearestCell, randomColor);
}

function changeColor(nearestCell, randomColor) {
    for (var i = -1; i <= 1; i++) {
        for (var j = -1; j <= 1; j++) {
            var x = nearestCell.column + i;
            var y = nearestCell.row + j;
            if (x >= 0 && y >= 0 && x < cellNum && y < cellNum) {
                cells[x][y].color = mixColor(cells[x][y].color, randomColor);
            }
        }
    }
    timeCheck = game.time.now;
}

Column and row order matters. The neighborhood must be centered on the chosen cell, not on the cell obtained by swapping its coordinates. Equal color distances use the last match encountered by the loop.

The remaining functions generate samples, compare colors, and perform the blend:

function getRandomRgb() {
    return {
        r: Math.floor(Math.random() * 256),
        g: Math.floor(Math.random() * 256),
        b: Math.floor(Math.random() * 256)
    };
}

function colorDistance(color1, color2) {
    return Math.hypot(
        color2.r - color1.r,
        color2.g - color1.g,
        color2.b - color1.b
    );
}

function mixColor(rgbColor1, rgbColor2) {
    var oldColor = rgb2cmyk(rgbColor1);
    var sample = rgb2cmyk(rgbColor2);
    var mixed = {};
    for (var channel of ["c", "m", "y", "k"]) {
        mixed[channel] = (oldColor[channel] + 0.1 * sample[channel]) / 1.1;
    }
    var rgb = cmyk2rgb(mixed);
    return {r: Math.round(rgb.r), g: Math.round(rgb.g), b: Math.round(rgb.b)};
}

function rgb2cmyk (rgbColor) {
    var r = rgbColor.r / 255;
    var g = rgbColor.g / 255;
    var b = rgbColor.b / 255;
    var max = Math.max(r, g, b);
    if (max === 0) {
        return {c: 0, m: 0, y: 0, k: 1};
    }
    return {c: 1 - r / max, m: 1 - g / max, y: 1 - b / max, k: 1 - max};
}

function cmyk2rgb (cmykColor) {
    return {
        r: 255 * (1 - cmykColor.c) * (1 - cmykColor.k),
        g: 255 * (1 - cmykColor.m) * (1 - cmykColor.k),
        b: 255 * (1 - cmykColor.y) * (1 - cmykColor.k)
    };
}

For N cells, each step takes O(N) time to find the best match. The neighborhood update touches at most nine cells, so it adds constant work. The grid uses O(N) space.

The complete demo JavaScript, including initialization and drawing, is hosted with this article.