Flood-It game in JavaScript

Turn: /

This is a JavaScript game consisting of trying to flood fill the entire box with one colour using twenty-five or fewer "flood fills". It's based on floodit.appspot.com, which I found via How to optimally solve the flood fill puzzle? However, this is a from-scratch reimplementation.

There is also version without the programming details here: Play the game

flood-game.js

This is the JavaScript for the game:
var n_rows = 14;
var n_cols = 14;
var start_table = new Array (n_rows);
for (var row = 0; row < n_rows; row++) {
    start_table[row] = new Array (n_cols);
}
var colours = "blue red green yellow pink purple".split (/\s+/);

var userReact = true;
/* DOM functions. */

function create_node (type, parent)
{
    var new_node = document.createElement (type);
    parent.appendChild (new_node);
    return new_node;
}

function append_text (parent, text)
{
    var text_node = document.createTextNode (text);
    clear (parent);
    parent.appendChild(text_node);
}

function getByID (id)
{
    var element = document.getElementById (id);
    return element;
}

function clear (element)
{
    while (element.lastChild)
        element.removeChild (element.lastChild);
}

/* Flood fill game. */

var moves;
var max_moves = 25;
var finished;
var nFlooded = 0;

/* Alter a flooded element's colour. */
function colourFlooded(row, col, colour)
{
    game_table[row][col].colour = colour;
    game_table[row][col].element.className = "piece " + colour;
}

/* Mark a square as not flooded. This is for the undo function. */
function unfloodSq(row, col) {
    game_table[row][col].flooded = false;
    game_table[row][col].move = unmovedState;
}

/* Mark a square as flooded, and record the move number in the square
   so that the move can be undone. */
function floodSq(row, col, move) {
    game_table[row][col].flooded = true;
    game_table[row][col].move = move;
}

/* The square specified by row and col is adjacent to a flooded
   square, so see if we can flood this one too. */
function floodAdjacent(row, col, move) {
    if (game_table[row][col].flooded)
        return;
    if (game_table[row][col].colour == move.colour) {
        /* This is the right colour, so flood it. */
        nFlooded++;
        floodSq(row, col, move.move);
        /* Recurse to get connected neighbours. */
        floodNeighbours(row, col, move);
    }
}

/* Given a square at row, col which is flooded, look at all its
   neighbours to see if they can be flooded too. */
function floodNeighbours(row, col, move)
{
    if (row < n_rows - 1)
        floodAdjacent(row + 1, col, move);
    if (row > 0)
        floodAdjacent(row - 1, col, move);
    if (col < n_cols - 1)
        floodAdjacent(row, col + 1, move);
    if (col > 0)
        floodAdjacent(row, col - 1, move);
}

/* This returns true if the entire board is flooded, false if not. */
function all_flooded ()
{
    for (var row = 0; row < n_rows; row++) {
        for (var col = 0; col < n_cols; col++) {
            if (! game_table[row][col].flooded) {
                return false;
            }
        }
    }
    return true;
}

/* Disable one of the colour choice buttons. */
function disableChosen(colour) {
    var allbuttons = document.getElementsByClassName("button")
    for (var i = 0; i < allbuttons.length; i++) {
        if (allbuttons[i].classList.contains(colour)) {
            allbuttons[i].style = "opacity: 0%;"
        } else {
            allbuttons[i].style = "opacity: 100%;";
        }
    }
}

/* Choices made by the user. This is for the undo function. */
var choices = new Array();

/* Save the move so that it can be undone. */
function saveMove(move) {
    choices.push(move);
}

/* Do "action" on all the squares of game_table. */
function allSq(action) {
    for (var row = 0; row < n_rows; row++) {
        for (var col = 0; col < n_cols; col++) {
            action(row, col);
        }
    }
}

/* Flood the board with a new colour specified by move.colour. */
function flood(move, initial)
{
    if (finished)
        return;
    var old_colour = game_table[0][0].colour;
    /* Check this is not the same as the old colour. */
    if (! initial && move.colour == old_colour)
        return;
    /* Check how many squares have changed. */
    nFlooded = 0;
    /* Change the colour of all the already-flooded elements. */
    allSq(function(row, col) {
        if (game_table[row][col].flooded) {
            colourFlooded (row, col, move.colour);
            floodNeighbours(row, col, move);
        }
    });
    clearStatus();
    /* In the initial case only, it's OK if nothing has changed. */
    if (initial || nFlooded > 0) {
        moves++;
        saveMove(move);
        if (! initial) {
            react();
        }
    } else {
        /* Ignore this move as it was useless. */
        useless();
    }
    if (all_flooded()) {
        finished = true;
        if (moves <= max_moves) {
            winner();
        } else {
            setStatus("🥱 Finished, at last! 😴");
        }
    } else if (moves >= max_moves) {
        setStatus("😞 You lost! 💔");
    }
    updateTurn();
    disableChosen(move.colour);
}

function remainingColours() {
    var remaining = new Object();
    allSq(function(row, col) {
        var sq = game_table[row][col];
        if (! sq.flooded) {
            remaining[sq.colour] = true;
        }
    });
    return Object.keys(remaining).length;
}

function rando(arr) {
    var r = Math.floor(Math.random()*arr.length);
    setStatus(arr[r])
}

var notLookingGood = [
    "⚕️ Prognosis: negative 😷",
    "Things are looking gloomy!",
    "There may be trouble ahead...",
    "It's not whether you win or lose, it's how you play the game.",
    "Il y a beaucoup de dangers!",
];

var previousRemaining = 6;

/* React to the user's play. */
function react() {
    if (! userReact) {
        return
    }
    var remaining = remainingColours();
    var movesLeft = max_moves - moves;
    if (remaining > movesLeft) {
        rando(notLookingGood);
        return;
    }
    if (remaining < previousRemaining) {
        setStatus("All of the " + game_table[0][0].colour + " squares have been flooded");
        previousRemaining = remaining;
        return;
    }
    /* I don't think it's possible to get more than 30 at once. */
    if (nFlooded > 25) {
        setStatus("🤯 Mind Boggling! 🤯");
    } else if (nFlooded > 20) {
        setStatus("🐶 Wowzer! 🐱");
    } else if (nFlooded > 15) {
        setStatus("🚀 Star player! ⭐");
    } else if (nFlooded > 10) {
        setStatus("🔥 On fire!");
    } else if (nFlooded > 6) {
        setStatus("😍 Great!");
    } else if (nFlooded > 4) {
        setStatus("😎 Nice!");
    } else if (nFlooded > 2) {
        setStatus("👍");
    } else if (nFlooded == 1) {
        if (moves > 2) {
            setStatus("🥱");
        }
    }       
}

/* Update the "Turn" counter. */
function updateTurn() {
    append_text (getByID ("moves"), moves);
}

/* Publish a congratulatory message indicating successful
   completion. */
function winner() {
    var winner = [
        "🎉 You win! 🥂",
        "🎈 Congratulations! 🎈",
        "🍗 Winner, winner, chicken dinner! 🍗",
        "🏆 An overwhelming victory! 🏆",
        "🎯 You have nailed it! 🎯",
        "🍒 You are the champion, my friend! 🍈",
    ];
    var r = max_moves - moves;
    if (r >= winner.length) {
        r = winner.length - 1;
    }
    setStatus(winner[r]);
}

var nUseless = 0;

/* Make a silly response when the user keeps clicking invalid
   choices. */
function useless() {
    nUseless++;
    if (nUseless < 5) {
        setStatus("There were no adjacent squares with that colour!");
        return;
    }
    rando(suggest);
}

    var suggest = [
        "🍜 Use your noodle",
        "🍄 Better lay off the mushrooms",
        "🎓 Put on your thinking cap",
        "🐻 You may not be smarter than the average bear",
        "🗿 The secret is to bang the rocks together, guys",
    ];


var statusEl

function status() {
    statusEl = getByID("status");
    return statusEl;
}

function clearStatus() {
    if (! userReact) {
        return;
    }
    clear(status());
}

function setStatus(msg) {
    if (! userReact) {
        return
    }
    var s = status();
    clear(s);
    s.innerHTML = msg;
}

function help ()
{
    setStatus ("Press the circle buttons to flood fill the image\n"+
               "with the colour from the top left corner. Fill the\n"+
               "entire image with the same colour in twenty-five or\n"+
               "fewer flood fills.");
}

/* Create a random colour for "createTable". */
function random_colour ()
{
    var colour_no = Math.floor (Math.random () * 6);
    return colours[colour_no];
}

/* The "state of play" is stored in game_table. */
var game_table = new Array (n_rows);
for (var row = 0; row < n_rows; row++) {
    game_table[row] = new Array (n_cols);
    for (var col = 0; col < n_cols; col++) {
        game_table[row][col] = new Object ();
    }
}

/* This square has not had anything done to it. */
const unmovedState = -1;
/* This square is part of the "initial state", it is either the top
   left square on starting the game, or a square connected to that and
   with the same colour. */
const initialState = 0;

/* Create the initial table. Set ran to true to make a random table,
   else the table is loaded from the existing values of game_table. */
function createTable(ran)
{
    moves = -1;
    finished = false;
    var game_table_element = getByID ("game-table-tbody");
    clear(game_table_element);
    for (var row = 0; row < n_rows; row++) {
        var tr = create_node ("tr", game_table_element);
        for (var col = 0; col < n_cols; col++) {
            var td = create_node ("td", tr);
            if (ran) {
                var colour = random_colour ();
                game_table[row][col].colour = colour;
            }
            td.className = "piece " + game_table[row][col].colour;
            unfloodSq(row, col);
            start_table[row][col] = colour;
            game_table[row][col].element = td;
        }
    }
    /* Mark the first element of the table as flooded. */
    floodSq(0, 0, initialState);
    var initialColour = game_table[0][0].colour;
    /* Initialize the adjacent elements with the same colour to be flooded
       from the outset. */
    flood({colour: initialColour, move: initialState}, true);
    append_text(getByID("max-moves"), max_moves);
    if (ran) {
        /* Save the initial board. */
        storeInitial();
    }
}

/* Click function for the colour buttons. */
function choose(colour) {
    flood({colour: colour, move: moves + 1});
}

/* Reset the board and variables. */
function resetBoard() {
    previousRemaining = 6;
    choices = new Array();
}

/* Start a new game. */
function newGame ()
{
    nUseless = 0;
    resetBoard();
    clearSave();
    squashed = "";
    createTable(true);
}

function clearSave() {
    var url = document.location.href;
    var parts = url.split('?');
    url = parts.shift();
    window.history.pushState('', document.title, url);
}

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay));
}

/* Show the user the solver's solution, with a delay between each
   move. */
async function solution(response) {
    var sol = JSON.parse(response)
    if (! sol.ok) {
        setStatus("Solver complained as follows: " + sol.status)
        return
    }
    if (! sol.solved) {
        setStatus("The computer was not smart enough to solve this!")
        return
    }
    setDifficulty(sol.difficulty)
    userReact = false
    for (var i = 0; i < sol.choices.length; i++) {
        flood({colour: sol.choices[i], move: i+1}, false)
        await sleep(500);
    }
    userReact = true
}

function setDifficulty(d) {
    switch (d) {
    case 0:
        setStatus("🥱 Very easy!");
        return
    case 1:
        setStatus("☺️ Easy peasy!");
        return
    case 2:
        setStatus("😐 Quite difficult!");
        return
    case 3:
        setStatus("😕 Difficult!");
        return
    case 4:
        setStatus("😟 Very difficult!");
        return
    case 5:
        setStatus("😖 Extremely difficult!");
        return
    }
}


/* Send this board to the solver. */
function solve() {
    if (finished) {
        setStatus("The game is already solved!");
        return;
    }
    var board = new Array(n_rows);
    for (var i = 0; i < n_rows; i++) {
        board[i] = new Array(n_cols);
        for (var j = 0; j < n_cols; j++) {
            var sq = game_table[i][j]
            board[i][j] = {
                c: sq.colour,
                f: sq.flooded,
            }
        }
    }
    var game = {
        moves: moves,
        board: board,
    };
    var sender = new XMLHttpRequest();
    sender.onreadystatechange = function() {
        if (this.readyState == 4) {
            if (this.status == 200) {
                solution(sender.responseText);
            } else {
                setStatus("Failed to get a solution, you'll have to do it yourself!");
            }
        }
    }
    sender.open("POST", "/floodsolver/", true);
    sender.send(JSON.stringify(game));
}

function startAgain() {
    if (moves == 0) {
        setStatus("We are at the start already");
        return;
    }
    resetBoard();
    unsquash(squashed);
    createTable(false);
}

/* Mutex for undoing. User hitting the undo button repeatedly can
   cause weird problems. */
var undoing = false;

/* Undo the most recent move. */
function undo() {
    if (undoing) {
        return;
    }
    if (moves <= 0) {
        setStatus("There is nothing to undo, pick a colour first.");
        return;
    }
    undoing = true;
    if (finished) {
        finished = false;
    }
    clearStatus();
    var lastMove = choices[choices.length - 1]
    var prevMove = choices[choices.length - 2]
    allSq(function(row, col) {
        var sq = game_table[row][col];
        if (sq.flooded) {
            if (sq.move == moves) {
                unfloodSq(row, col);
            } else {
                colourFlooded(row, col, prevMove.colour);
            }
        }
    });
    moves--;
    updateTurn();
    choices.pop();
    disableChosen(prevMove.colour);
    previousRemaining = remainingColours();
    undoing = false;
}

function colourToNumber(colour) {
    switch (colour) {
    case "blue":
        return 0;
    case "red":
        return 1;
    case "green":
        return 2;
    case "yellow":
        return 3;
    case "pink":
        return 4;
    case "purple":
        return 5;
    }
    throw("Unknown colour "+colour);
}

function numberToColour(num) {
    if (num >= colours.length) {
        throw("Number too big " + num);
    }
    return colours[num];
}

/* Convert n to letters/digits/URL-safe characters. */
function sixtysix(n) {
    if (n < 10) {
        return '0123456789'.charAt(n);
    }
    if (n < 36) {
        return 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.charAt(n - 10);
    }
    if (n < 62) {
        return 'abcdefghijklmnopqrstuvwxyz'.charAt(n - 36);
    }
    /* No, I do not care about putting the following in ASCII
       order. */
    switch (n) {
    case 62:
        return '-';
    case 63:
        return '.';
    case 64:
        return '_';
    case 65:
        return '~';
    }
}

function sqToNum(n) {
    var row = Math.floor(n / n_rows);
    var col = Math.floor(n % n_rows);
    if (col >= n_cols || row >= n_rows) {
        throw("Number out of bounds " + n);
    }
    var cname = game_table[row][col].colour;
    return colourToNumber(cname);
}

/* The initial state of play. */
var squashed = "";

/* Squash seven cells into three bytes of web-safe ASCII using
   6^7/66^3 ~=~ 1. This code assumes that n_cols is a multiple of
   seven. */
function storeInitial() {
    var n = n_rows * n_cols;
    var sevens = Math.floor(n / 7);
    squashed = ""
    for (var i = 0; i < sevens; i++) {
        var out = 0;
        for (var j = 0; j < 7; j++) {
            out = sqToNum(i*7+j) + 6*out;
        }
        if (out > 279936) { // 6^7
            throw("Out is too big "+out);
        }
        squashed = squashed.concat(sixtysix(out % 66));
        out = Math.floor(out/66);
        squashed = squashed.concat(sixtysix(out % 66)); 
        out = Math.floor(out/66);
        squashed = squashed.concat(sixtysix(out));
    }
}

function moveList() {
    var nc = choices.length
    var ml = "";
    for (var i = 0; i < nc; i++) {
        var ci = choices[i].colour
        if (ci == "purple") {
            ci = "l";
        } else {
            ci = ci.substring(0, 1);
        }
        ml += ci;
    }
    return ml;
}

function squash() {
    var url = '?b=' + squashed;
    if (moves > 0) {
        ml = moveList();
        url += "&m=" + ml;
    }
    window.history.pushState({}, '', url);
}

function setSq(n, colour) {
    var row = Math.floor(n / n_rows);
    var col = Math.floor(n % n_rows);
    if (col >= n_cols || row >= n_rows) {
        throw("Number out of bounds " + n);
    }
    var cname = numberToColour(colour);
    game_table[row][col].colour = cname;
}

function un66(q) {
    var c = q.charCodeAt(0);
    if (c >= 48 && c <= 57) {
        return c - 48;
    }
    if (c >= 65 && c <= 90) {
        return c - 65 + 10;
    }
    if (c >= 97 && c <= 122) {
        return c - 97 + 36;
    }
    switch (c) {
    case 45:
        return 62;
    case 46:
        return 63;
    case 95:
        return 64;
    case 126:
        return 65;
    }
    throw("Unknown char value " + c);
}

function unsquash(squashed) {
    var n = squashed.length;
    if (n != n_rows * n_cols * 3 / 7) {
        return false;
    }
    var threes = Math.floor(n / 3);
    for (var i = 0; i < threes; i++) {
        var t = i * 3;
        var a = un66(squashed.charAt(t))
        var b = un66(squashed.charAt(t + 1))
        var c = un66(squashed.charAt(t + 2))
        var cell = a + 66 * (b + 66 * c);
        for (var j = 0; j < 7; j++) {
            var o = i*7+(6-j);
            setSq(o, Math.floor(cell % 6));
            cell = Math.floor(cell / 6);
        }
    }
    return true;
}

function save() {
    squash();
    setStatus("Copy or bookmark the browser URL to reload this game board");
}

function letterToMove(letter) {
    switch (letter) {
        case "b": return "blue";
        case "g": return "green";
        case "p": return "pink";
        case "l": return "purple";
        case "r": return "red";
        case "y": return "yellow";
    }
    throw("Unknown letter code "+letter);
}

function doMoves(ml) {
    var n = ml.length
    for (var i = 0; i < n; i++) {
        var move = ml.substring(i, i+1);
        var colour = letterToMove(move);
        flood({colour: colour, move: moves + 1});
    }
}

function load() {
    var regex = new RegExp("b=([0-9a-zA-Z_~.-]+)");
    // ~ is not web safe apparently, or is it?
    var results = regex.exec(decodeURI(window.location.href));
    if (results === null) {
        createTable(true);
        return;
    }
    squashed = results[1];
    if (! unsquash(squashed)) {
        setStatus("The starting position '" + squashed + "' looks wrong.");
        return;
    }
    createTable(false);
    var moveregex = new RegExp("m=([bglpry]+)");
    var moveresults = moveregex.exec(window.location.href);
    if (moveresults != null) {
        doMoves(moveresults[1]);
    }
}


You also need some styles and HTML to make this work.

game-table.html

This is the HTML for the game table. Note that the actual board is dynamically created in JavaScript.
<div id="game-board">
  <div id="colour-buttons">
    <table id="button-table">
      <tr>
        <td class="button blue" onclick="choose('blue')">
        </td>
        <td class="button red" onclick="choose('red')">
        </td>
        <td class="button green" onclick="choose('green')">
        </td>
      </tr>
      <tr>
        <td class="button yellow" onclick="choose('yellow')">
        </td>
        <td class="button pink" onclick="choose('pink')">
        </td>
        <td class="button purple" onclick="choose('purple')">
        </td>
      </tr>
    </table>
  </div> <!-- colour-buttons -->
  <div id="flood-controls">
    <table>
      <tr valign="top">
        <td id="turn-counter" valign="top">
          Turn: <span id="moves"></span>/<span id="max-moves"></span>
        </td>
      </tr>
      <tr valign="top">
        <td valign="top">
          <input type="submit" value="New game" onclick="newGame()">
        </td>
      </tr>
      <tr valign="top">
        <td valign="top">
          <input type="submit" value="Help" onclick="help()">
          <input type="submit" value="Undo" onclick="undo()">
        </td>
      </tr>
      <tr valign="top">
        <td valign="top">
          <input type="submit" value="Start again" onclick="startAgain()">
        </td>
      </tr>
      <tr valign="top">
        <td valign="top">
          <input type="submit" value="Solve" onclick="solve()">
          <input type="submit" value="Save" onclick="save()">
        </td>
      </tr>
    </table>
  </div>  <!-- flood-controls -->
  <div id="status">
  </div> <!-- status -->
  <table id="game-table">
    <tbody id="game-table-tbody">
    </tbody>
  </table>
</div> <!-- game-board -->

flood-game.css

These styles make the box colours and the table layout.
.red {
    background-color: red;
}

.yellow {
    background-color: gold;
}

.green {
    background-color: #8A865D;
}

.blue {
    background-color: blue;
}

.purple {
    background-color: purple;
}

.pink {
    background-color: #e77254;
}

#game-table {
    margin: 0px;
    border: 0px;
    padding: 0px;
    border-spacing: 0px;
    width: 392;
    height: 448;
    background: goldenrod;
    table-layout: fixed;
}

#status {
    width: 120px;
    height: 228px;
}

.piece {
    width: 28px;
    height: 30px;
    border: 0px;
    padding: 0px;
    margin: 0px;
}

.button {
    width: 30px;
    height: 30px;
    border: 0px;
    border-radius: 15px;
}

#button-table {
    border-spacing: 4px;
    vertical-align: top;
}

#turn-counter {
    font-family: sans-serif;
}

@media screen and (min-width: 600px) {
    #game-board {
        display: grid;
        grid-template-columns: 120px max-content;
        grid-template-rows: 80px 140px max-content;
    }

    #game-table {
        grid-column-start: 2;
        grid-column-end: 3;
        grid-row-start: 1;
        grid-row-end: 4;
    }

    #colour-buttons {
        grid-column-start: 1;
        grid-column-end: 2;
        grid-row-start: 1;
    }

    #flood-controls {
        grid-column-start: 1;
        grid-column-end: 2;
        grid-row-start: 2;
        grid-row-end: 3;
    }

    #status {
        grid-column-start: 1;
        grid-column-end: 2;
        grid-row-start: 3;
        grid-row-end: 4;
    }
}

@media screen and (max-width: 600px) {
    #status {
        max-width: 100px;
    }

    #game-board {
        display: grid;
        grid-template-columns: 24vmin 1fr 1fr;
        gap: 10px;
        margin: auto;
    }

    #game-table {
        grid-column-start: 1;
        grid-column-end: 4;
        grid-row-start: 1;
        width: 94.5vmin;
        height: 94.5vmin;
    }

    #colour-buttons {
        grid-column-start: 1;
        grid-row-start: 2;
    }

    #flood-controls {
        grid-column-start: 2;
        grid-column-end: 4;
        grid-row-start: 2;
    }

    #status {
        grid-column-start: 3;
        grid-row-start: 2;
    }

    .button {
        width: 6vmin;
        height: 6vmin;
        border-radius: 3vmin;
    }

    .piece {
        width: 6.75vmin;
        height: 6.75vmin;
    }
}


Copyright © Ben Bullock 2009-2023. All rights reserved. For comments, questions, and corrections, please email Ben Bullock (benkasminbullock@gmail.com) or use the discussion group at Google Groups. / Privacy / Disclaimer