<Emanuele Feronato/>
home

Build a game like mobile smash hit Meowdoku in vanilla JavaScript in just a few lines of code

Are you playing Meowdoku? it’s a logic puzzle played on a square grid split into coloured areas. You have to place one cat in every row, in every column, and in every coloured area, with one extra constraint: no two cats can touch each other, not even diagonally.

If that sounds familiar, it should. It is the mechanic behind Queens, the puzzle LinkedIn added to its daily games line-up, and it turned into one of those things everybody is suddenly doing.

So here is my version, Bombdoku, with bombs. The rules are the same, and so is the reason it works: rows and columns behave like a sudoku, the coloured areas behave like a killer sudoku cage, and the adjacency rule adds a spatial flavour that pure number puzzles do not have.

Have a look at the game, built in vanilla JavaScript:

Click or drag to cross out the tiles where a bomb cannot be. Double click a tile when you are sure a bomb is hiding there. One bomb per row, per column and per coloured area, and no two bombs may touch, not even diagonally.

The whole game is a single HTML file with no libraries: everything you see on screen is a div inside a CSS grid, and the entire thing sits comfortably under two hundred lines including comments.

A level is just two arrays. The first one, areas, is a grid of numbers saying which coloured zone each tile belongs to. The second one, bombs, is the solution: for each row, the column holding the bomb. That is all the data the game needs, and it is small enough to paste directly into the source.

JavaScript
const level = {
    areas: [
        [ 0, 1, 1, 1, 1, 2, 2, 2, 2, 2 ],
        [ 5, 1, 3, 3, 1, 2, 2, 2, 3, 2 ],
        [ 5, 1, 3, 2, 2, 2, 3, 3, 3, 2 ],
        [ 5, 1, 3, 3, 2, 3, 3, 3, 3, 4 ],
        [ 5, 1, 5, 3, 3, 3, 4, 4, 4, 4 ],
        [ 5, 5, 5, 5, 5, 4, 4, 7, 7, 4 ],
        [ 9, 9, 9, 5, 5, 6, 4, 7, 7, 4 ],
        [ 9, 5, 5, 5, 5, 5, 5, 7, 7, 4 ],
        [ 9, 5, 8, 8, 8, 5, 7, 7, 7, 7 ],
        [ 9, 9, 8, 8, 7, 7, 7, 7, 7, 7 ]
    ],
    bombs: [0, 4, 9, 6, 8, 2, 5, 7, 3, 1]
};

Tiles mirror the level. The board is built as a two dimensional array of div elements, so tiles[row][col] and level.areas[row][col] always line up and no index arithmetic is needed anywhere. Painting the board is a nested forEach that assigns a colour from a palette indexed by area number.

The drag decides its mode once. The naive approach, toggling every tile the pointer crosses, feels broken as soon as you drag over a tile you already marked, because it erases it. The fix is to read the starting tile on mousedown and store whether this stroke adds crosses or removes them, then apply that same decision to every tile the pointer visits. Start on an empty tile and you always mark, start on a marked one and you always erase.

Single click and double click do not fight. A double click is preceded by two ordinary clicks, so a naive implementation makes the cross blink before the cat appears. Two things prevent it. The detail property of the mouse event tells you which click of the burst you are handling, so the second one can be ignored. And the single click is applied on a 250 millisecond delay, which the double click handler cancels with clearTimeout. The delay is invisible while dragging, because there the mark appears as soon as the pointer moves.

Winning is a counter. Every first time reveal of a cat increases it, and when it matches the board size an alert fires, deferred with a zero delay setTimeout so the browser can paint the last cat before the dialog freezes the page.

Here is the source code, fully commented. Just copy and paste in a HTML file.

HTML
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>Bombdoku</title>
        <style>

            /* The page holds only the board and the button, stacked and centred. */
            body {
                margin: 0;
                min-height: 100dvh;
                display: flex;
                flex-direction: column;
                align-items: center;
                justify-content: center;
                gap: 20px;
            }

            /* The game board. */
            #gameboard { 
                display: grid; 
                gap: 1px; 
                width: max-content;
                user-select: none;
            }

            /*
                Fixed size tiles.
                A line-height equal to the height centres the symbol vertically with nothing else needed.
            */
            #gameboard div {
                width: 32px;
                height: 32px;
                font-size: 20px;
                line-height: 32px;
                text-align: center;
            }

            /* The restart button. */
            #restart {
                font-weight: bold;
                font-size: 24px;
                padding: 4px 10px;
            }
        </style>
    </head>
    <body>
        
        <!-- Tiles are created by the JS, this is just the empty container. -->
        <div id="gameboard"></div>
        
        <!-- The restart button. -->
        <button id="restart">Restart</button>
        
        <script>
            /*
                A level is defined by two things:
                1 - areas says which coloured zone every tile belongs to, read row by row.
                2 - bombs is the solution: for each row, the column holding the bomb.
                This prototype assumes you are playing a level which follows the rules.
            */
            const level = {
                areas: [
                    [ 0, 1, 1, 1, 1, 2, 2, 2, 2, 2 ],
                    [ 5, 1, 3, 3, 1, 2, 2, 2, 3, 2 ],
                    [ 5, 1, 3, 2, 2, 2, 3, 3, 3, 2 ],
                    [ 5, 1, 3, 3, 2, 3, 3, 3, 3, 4 ],
                    [ 5, 1, 5, 3, 3, 3, 4, 4, 4, 4 ],
                    [ 5, 5, 5, 5, 5, 4, 4, 7, 7, 4 ],
                    [ 9, 9, 9, 5, 5, 6, 4, 7, 7, 4 ],
                    [ 9, 5, 5, 5, 5, 5, 5, 7, 7, 4 ],
                    [ 9, 5, 8, 8, 8, 5, 7, 7, 7, 7 ],
                    [ 9, 9, 8, 8, 7, 7, 7, 7, 7, 7 ]
                ],
                bombs: [0, 4, 9, 6, 8, 2, 5, 7, 3, 1]
            };
           
            // Colors used to fill each area.
            const colors = [
                '#9f6f50',
                '#9dd388',
                '#c6768f',
                '#f6db8e',
                '#eb9fe1',
                '#5aa6bf',
                '#c8a636',
                '#eda068',
                '#877ad6',
                '#498a5a'
            ];

            // Board setup.
            const tableSize = level.areas.length;
            const gameBoardElement = document.getElementById('gameboard');
            gameBoardElement.style.gridTemplateColumns = 'repeat(' + tableSize + ', 32px)';
            let bombs = [];
            
            // How many bombs the player has revealed so far.
            let found = 0;                                   
            
            // Symbols used in the game.
            const bomb = '?';
            const cross = '?';                               
            const wrong = '?';

            // Variables used to manage mouse actions.
            let isMouseDown = false;
            let isMouseDragged = false;
            let addingCrosses = true;
            let firstTile = null;
            let pendingClick = null;

            // When Restart button is clicked, call startGame funcion.
            document.getElementById('restart').onclick = startGame;

            // Board setup.
            const tiles = [];
            for (let row = 0; row < tableSize; row++) {
            
                // Tiles of this row, so tiles matches the shape of level.areas.
                const tileRow = [];
                
                for (let col = 0; col < tableSize; col++) {
                
                    // Creation of a new div element.
                    const tile = document.createElement('div');
                
                    // Triggered when mouse is down on a tile.
                    tile.onmousedown = (event) => {
                        event.preventDefault();
                        
                        /*
                            detail counts the clicks in the current burst: when it's 1,
                            it's a single click.
                        */
                        if (event.detail == 1) {  
                            
                            // The mouse is down.
                            isMouseDown = true; 

                            // Player is dragging.
                            isMouseDragged = false; 

                            // Current tile is the first tile.
                            firstTile = tile;

                            // Are we adding crosses? Yes, if tile content is not a cross.
                            addingCrosses = tile.textContent !== cross;             
                        }
                    }

                    // Triggered when the mouse hovers a tile.
                    tile.onmouseover = () => {
                    
                        // Checks if the mouse button is down
                        if (isMouseDown) {

                            /*
                                The first movement proves this is a drag and not a click,
                                so the starting tile gets marked as well.
                            */
                            if (!isMouseDragged) {

                                // Now we are dragging
                                isMouseDragged = true;

                                // Mark first tile.
                                mark(firstTile);
                            }
                            mark(tile);
                        }
                    };

                    // Triggered on double click.
                    tile.ondblclick = () => {
                    
                        /* 
                            Cancel the single click still waiting, otherwise the cross
                            would flash for an instant before the real result.
                        */
                        clearTimeout(pendingClick);
                        pendingClick = null;       
                        
                        /*
                            row and col are the loop counters. They are declared with let,
                            so every iteration keeps its own pair for this closure.
                        */
                        if (bombs[row] !== col) {
                            tile.textContent = wrong;
                            return;
                        }
                        
                        // Count this bomb only the first time it is revealed.
                        if (tile.textContent !== bomb) {
                            found++;
                        }
                        tile.textContent = bomb;
                        
                        /*
                            The alert is deferred so the browser paints the last bomb
                            before the dialog freezes everything.
                        */
                        if (found === tableSize) {
                            setTimeout(() => alert('You found them all!'), 0);
                        }
                    };
                    
                    // Add tile to this row.
                    tileRow.push(tile);

                    // Add tile element to game board.
                    gameBoardElement.appendChild(tile);
                }
                
                // Add the finished row to tiles array.
                tiles.push(tileRow);
            }

            // Start the game.
            startGame();

            function startGame() {
                bombs = level.bombs;
                found = 0;
                
                /*
                    Paint the board.
                    forEach gives the element first and the index second, so areaRow is
                    a whole row of area numbers and area is a single one of them.
                */
                level.areas.forEach((areaRow, row) => {
                    areaRow.forEach((area, col) => {
                        tiles[row][col].textContent = '';
                        tiles[row][col].style.background = colors[area];
                    })
                })
            }

            /*
                mouseup lives on the document rather than on a tile, so a drag also
                ends when the button is released outside the board.
                
                The single click is applied on a delay because at that moment
                there is no way to know whether a second click is coming.
                
            */
            document.addEventListener('mouseup', () => {
                if (isMouseDown && !isMouseDragged) {
                    const tile = firstTile;
                    pendingClick = setTimeout(() => {
                        mark(tile);
                        pendingClick = null
                    }, 250);
                }
                isMouseDown = false;
            });

            // Function to mark a tile.
            function mark(tile) {

                // An already revealed bomb must not be wiped by dragging over it.
                if (tile.textContent !== bomb) {
                    tile.textContent = addingCrosses ? cross : '';
                }
            }    
        </script>
    </body>
</html>

What comes next? The hard part of a game like this is not the game, it is the levels. Generating a random valid board is easy, but a good level must be solvable by pure deduction, without ever guessing, and randomly grown areas almost never are. In a future post we will look at how to build levels that can be reasoned out from start to finish, and how to prove it.

231 games covered