Exploring Color Matching in JavaScript*

by Liza Shulyayeva

This is a bit of a post-mortem on my entry, TSVETT, into the js13kGames challenge this year. This writeup focuses on the main theme of the game – color matching.

When I started my js13kGames entry for the year I thought it’d be quick an easy. I don’t really enter these things to win, so I didn’t think it would turn into something overly time consuming. Pick RGB values at random; get the player to try to match them as closely as possible. Display the score as a percentage; have them to try to attain the highest average score across five stages of gameplay. Done!

I started by having the player manipulate RGB values to match the values of the target color. For scoring, I thought getting the difference between the player color and target color RGB values individually, getting the percentage accuracy of each, then adding them up and dividing by three would be sufficient.

When I invited people to try out the game and give me feedback before submitting it to js13kGames, I learned two big things:

1. The RGB color model was not the most intuitive choice for most players.

I’ve worked with RGB a lot and to me it’s simple enough to get an idea of how much of each red, green, and blue a color will likely have. It’s not mixing paint, but it almost felt like it – of course this purple has more red in it. Can’t you see the slightly warmer reddish hue? No. RGB, though so prevalent among web color utilizations, is not quite as intuitive as I thought.

A few people suggested HSV, but I ended up going with HSL as it is supported in CSS3 (and by extension the canvas fillStyle property):

// HSL
// H (Hue): 0-360 degree on color wheel, S (Saturation): 0-100 saturation percentage, L (Lightness): 0-100% lightness percentage. 
ctx.fillStyle = 'hsl(360, 50%, 50%)';
 
// RGB
// 0-255 values for amounts of Red, Green, and Blue respectively.
ctx.fillStyle = 'rgb(255, 255, 255)';

(You can also specify an alpha channel by using HSLA just like you would RGBA.)

For most people, HSL seems more intuitive than RGB when it comes to getting an idea of how modifying the values will actually impact the color you’re seeing. For a player it can be frustrating to try to guess which colors will mix how with RGB, at least until they learn the general mixing tendencies of the color model. With HSL, instead of modifying amounts of Red, Green, and Blue we modify Hue, Saturation, and Lightness, allowing for more accurate prediction of what you’re actually doing to the end color.

RGB is useful (in fact, I ended up having to convert HSLback to RGB later for score calculation), but HSL got much better feedback from players helping me test the game.

2. My accuracy calculations weren’t very accurate.

Meet Lucky. Lucky tries to bounce all the way across the screen before time runs out. His speed depends on how close your current color choice is to the target color you’re meant to be matching. This means that the player’s score is recalculated continuously as they play, with Lucky’s speed being adjusted accordingly based on how well the player is doing.

Here’s what my original scoring formula looked like:

var rDiff = Math.abs(targetColor.r - playerColor.r);
    var gDiff = Math.abs(targetColor.g - playerColor.g);
    var bDiff = Math.abs(targetColor.b - playerColor.b);
 
    var rScore = 100 - (rDiff / 255 * 100);
    var gScore = 100 - (gDiff / 255 * 100);
    var bScore = 100 - (bDiff / 255 * 100);
 
    currentStageScore = Math.round((rScore + gScore + bScore) / 3);

It seemed OK in theory and kind-of-sort-of-worked, but it would sometimes give a higher score for two colors that looked vastly different than it would for two colors that appeared to be almost the same.

Yuriy O’Donnel, who was helping me test the game and provided some great suggestions for improvements, showed me that the existing scoring thought these two colors were allegedly very similar (Lucky was sprinting across the screen at full speed):

Whereas these two were shown to not be similar enough for Lucky to move at what would seem to be a reasonable pace:

In other words – it was kind of crap.

After doing more reading on color theory and color difference, I learned a few things:

  • Color perception is much more complicated than averaging a few numbers to figure out how close they are. The human eye is more sensitive to some colors than others.
  • There are a few different formulae to choose from if you want to calculate the magical Delta-E value, which the International Commission on Illustration’s (CIE) metric for color distance.
  • I’d have to convert my HSL values to LAB to use them. LAB is a color model designed to most closely resemble human color perception.

I am now converting my color values for the player’s color and the target color from the HSL color model to RGB to XYZ and finally to LAB using existing conversion algorithms applied to JavaScript. I’m then using CIE94 to get the Delta-E value.

Using this scoring method, the Delta-E value for the first example image above would be 15.9, resulting in an actual score of 84.1 (100 – 15.9). This certainly wouldn’t allow Lucky to travel anywhere close to his maximum speed.

The second example (which was deemed to not be close enough by the original calculation) will get a Delta-E value of 3.4, meaning a score of 96.6, allowing Lucky to run very close to his maximum speed.

Here is what the current scoring-related functions look like. Keep in mind that all of the conversion and delta-E calculation formulae already exist and are readily available online, and just need to be applied to your requirements/language of choice (I will list some useful sources below).

// Check how the player is doing in current stage.
function checkScore() {
    var currentStageScore = 0;
 
    var rgb = hslToRgb(playerColor.h/360, playerColor.s/100, playerColor.l/100);
    var xyz = rgbToXyz(rgb[0], rgb[1], rgb[2]);
    var lab = xyzToLab(xyz[0], xyz[1], xyz[2]);
    var diff = cie1994(lab, targetColor.lab, false);
 
    currentStageScore = parseFloat((100 - diff).toFixed(2));
    stageResultsArr[stage - 1] = currentStageScore;
};
/* To score color accuracy properly we need to convert HSL to LAB and then get Delta-E by using CIE94 formula */
/* To do this we need to convert HSL to RGB to XYZ to LAB, then run CIE94 formula */
 
// Convert HSL to RGB
function hslToRgb(h, s, l){
    var r, g, b;
 
    if (s == 0){
        r = g = b = l;
    }
    else{
        function hue2rgb(p, q, t){
            if (t < 0) t += 1;
            if (t > 1) t -= 1;
            if (t < 1/6) return p + (q - p) * 6 * t;
            if (t < 1/2) return q;
            if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
            return p;
        }
 
        var q = l < 0.5 ? l * (1 + s) : l + s - l * s;
        var p = 2 * l - q;
        r = hue2rgb(p, q, h + 1/3);
        g = hue2rgb(p, q, h);
        b = hue2rgb(p, q, h - 1/3);
    }
 
    return [r * 255, g * 255, b * 255].map(Math.round);
};
// Convert RGB to XYZ
function rgbToXyz(r, g, b) {
    var _r = (r / 255);
    var _g = (g / 255);
    var _b = (b / 255);
 
    if (_r > 0.04045) {
        _r = Math.pow(((_r + 0.055) / 1.055), 2.4);
    }
    else {
        _r = _r / 12.92;
    }
 
    if (_g > 0.04045) {
        _g = Math.pow(((_g + 0.055) / 1.055), 2.4);
    }
    else {                 
        _g = _g / 12.92;
    }
 
    if (_b > 0.04045) {
        _b = Math.pow(((_b + 0.055) / 1.055), 2.4);
    }
    else {                  
        _b = _b / 12.92;
    }
 
    _r = _r * 100;
    _g = _g * 100;
    _b = _b * 100;
 
    X = _r * 0.4124 + _g * 0.3576 + _b * 0.1805;
    Y = _r * 0.2126 + _g * 0.7152 + _b * 0.0722;
    Z = _r * 0.0193 + _g * 0.1192 + _b * 0.9505;
 
    return [X, Y, Z];
};
// Convert XYZ to LAB
function xyzToLab(x, y, z) {
    var ref_X =  95.047;
    var ref_Y = 100.000;
    var ref_Z = 108.883;
 
    var _X = x / ref_X;
    var _Y = y / ref_Y;
    var _Z = z / ref_Z;
 
    if (_X > 0.008856) {
         _X = Math.pow(_X, (1/3));
    }
    else {                 
        _X = (7.787 * _X) + (16 / 116);
    }
 
    if (_Y > 0.008856) {
        _Y = Math.pow(_Y, (1/3));
    }
    else {
      _Y = (7.787 * _Y) + (16 / 116);
    }
 
    if (_Z > 0.008856) {
        _Z = Math.pow(_Z, (1/3));
    }
    else { 
        _Z = (7.787 * _Z) + (16 / 116);
    }
 
    var CIE_L = (116 * _Y) - 16;
    var CIE_a = 500 * (_X - _Y);
    var CIE_b = 200 * (_Y - _Z);
 
    return [CIE_L, CIE_a, CIE_b];
};
// Finally, use cie1994 to get delta-e using LAB
function cie1994(x, y, isTextiles) {
    var x = {l: x[0], a: x[1], b: x[2]};
    var y = {l: y[0], a: y[1], b: y[2]};
    labx = x;
    laby = y;
    var k2;
    var k1;
    var kl;
    var kh = 1;
    var kc = 1;
    if (isTextiles) {
        k2 = 0.014;
        k1 = 0.048;
        kl = 2;
    }
    else {
        k2 = 0.015;
        k1 = 0.045;
        kl = 1;
    }
 
    var c1 = Math.sqrt(x.a * x.a + x.b * x.b);
    var c2 = Math.sqrt(y.a * y.a + y.b * y.b);
 
    var sh = 1 + k2 * c1;
    var sc = 1 + k1 * c1;
    var sl = 1;
 
    var da = x.a - y.a;
    var db = x.b - y.b;
    var dc = c1 - c2;
 
    var dl = x.l - y.l;
    var dh = Math.sqrt(da * da + db * db – dc * dc);
 
    return Math.sqrt(Math.pow((dl/(kl * sl)),2) + Math.pow((dc/(kc * sc)),2) + Math.pow((dh/(kh * sh)),2));
};

I’m sure it’s not perfect and there may be some detail I missed in my rush to meet the deadline, but it seems to achieve a much greater degree of accuracy than the original implementation.

Useful Links

I have scoured various color-related corners of the Internet to find a decent way to implement this. Some useful sources I came across included:

For more complete information about compiler optimizations, see our Optimization Notice.