4
\$\begingroup\$

I am trying to implement the game 2048 using JavaScript. I am using a two-dimensional array to represent the board. For each row, it is represented using an array of integers.

Here I am focused on implementing the merge left functionality i.e. the merge that happens after the user hits left on their keyboard.

Here are a set of test cases that I came up with

const array1 = [2, 2, 2, 0] //  [4,2,0,0]
const array2 = [2, 2, 2, 2] // [4,4,0,0]
const array3 = [2, 0, 0, 2] // [4,0,0,0]
const array4 = [2, 2, 4, 16] // [4,4,16,0]

Here is one workable solution:

function mergeLeft(array) {
    let startIndex = 0;
    for (let endIndex = 1; endIndex < array.length; endIndex++) {
        if (array[endIndex] === 0) continue;
        let target = array[startIndex];
        if (target === 0 || target === array[endIndex]) { // shift or merge
            array[startIndex] += array[endIndex];
            array[endIndex] = 0;
        } else if (startIndex + 1 < endIndex) {
            endIndex--; // undo the next for-loop increment
        }
        if (target !== 0) startIndex++;
    }
    return array;
}

This works but there are a lot of if-else checking that makes it hard to extend or adapt to merges in other directions e.g. right, up, down.

Is there a better way to design the algorithm or the function so it can be more easily extensible to other directions? Any other feedback is welcomed!

In addition, I would like to ask a few more questions:

  1. If we are going to build this game using a traditional object-oriented way, what are the class we need to design here?
  2. I figured that we can use a 2-dimensional array to represent the board, or as one of the answers presented, we can use a 1-dimensional array to hold x,y coordinates, I wonder what are some of the pros and cons with either approach?
  3. I was looking at it from an OOD approach and I was also aware that we can use functional programming instead. Here is my attempt
const reverse = (array) => [...array].reverse()

const transpose = (grid) => grid[0].map((_, i) => grid.map((r) => r[i]))

const rotateClockwise = (grid) => transpose(reverse(grid))

const rotateCounter = (grid) => reverse(transpose(grid))

// helper functions
const shift = ([n0, n1, ...ns]) =>
  n0 == undefined
    ? []
    : n0 == 0
    ? shift([n1, ...ns])
    : n1 == 0
    ? shift([n0, ...ns])
    : n0 == n1
    ? [n0 + n1, ...shift(ns)]
    : [n0, ...shift([n1, ...ns])]

const fillZeros = row => 
    row.concat ([0, 0, 0, 0]) .slice (0, 4)
  
const merge = ([firstItem, secondItem, ...rest]) =>
    firstItem == undefined
      ? []
    : firstItem == 0
      ? merge ([secondItem, ...rest])
    : secondItem == 0
      ? merge ([firstItem, ...rest])
    : firstItem == secondItem
      ? [firstItem + secondItem, ... merge (rest)]
    : [firstItem, ...merge ([secondItem, ... rest])]
  

const mergeRow = (row) => 
    merge(row).concat([0, 0, 0, 0]).slice (0, 4)
  
const mergeLeft = (grid) => grid.map(mergeRow)
const mergeRight = (grid) => grid.map((row) => reverse(mergeRow(reverse(row))))
const mergeUp = (grid) => rotateClockwise(mergeLeft(rotateCounter(grid)))
const mergeDown = (grid) => rotateClockwise(mergeRight(rotateCounter(grid)))

However I cannot pinpoint exactly which approach i.e. functional programming vs. OOP is better. Can someone give me some concrete benefits for going with either approach?

\$\endgroup\$

2 Answers 2

3
+50
\$\begingroup\$

One way to simplify your code a lot is to generate a new board in your mergeLeft() function, instead of trying to modify it in place. It opens up room to use other techniques that would greatly simplify the logic in your function, like, popping results from the source array as you work with them, or filtering out zeros.

An example of how you might implemented it in this format:

const peek = array => array[array.length - 1];

function mergeRowRight(sparseRow) {
  const row = sparseRow.filter(x => x !== 0);
  const result = [];
  while (row.length) {
    let value = row.pop();
    if (peek(row) === value) value += row.pop();
    result.unshift(value);
  }

  while (result.length < 4) result.unshift(0);
  return result;
}

I see no reason to need to generalize that algorithm to work in all directions. Instead, you can derive the other directions from your original mergeLeft() function, it just takes some reversing and zipping (I'm using zip() in this scenario to flip the board along its diagonal, but a function like this has many other uses).

Here's how you might derive the different directions from mergeRowRight().

function zip(arrays) {
  const result = [];
  for (let i = 0; i < arrays[0].length; ++i) {
    result.push(arrays.map(array => array[i]));
  }
  return result;
}

const mergeRowLeft = row => mergeRowRight([...row].reverse()).reverse();

const mergeRight = board => board.map(mergeRowRight);
const mergeLeft = board => board.map(mergeRowLeft);
const mergeUp = board => zip(zip(board).map(mergeRowLeft));
const mergeDown = board => zip(zip(board).map(mergeRowRight));

Update: Responses to additional questions that the O.P. added

I was looking at it from an OOD approach and I was also aware that we can use functional programming instead. However, I cannot pinpoint exactly which approach i.e. functional programming vs. OOP is better. Can someone give me some concrete benefits for going with either approach?

I wouldn't really call your initial attempt "object-oriented" - you don't have any classes, private data, encapsulation, etc. It looks more like procedural programming - it's a list of instructions that can be followed step by step to achieve the desired outcome. OOP tries to organize logic into different classes of objects while functional programming tries to organize logic into modules and functions. These strengths of the different paradigms tailor to different types of problems being solved. For example:

  • If your problem is largely algorithmic in nature (like this one), then functional programming will really shine. Functional programming does a really good job at subdividing an algorithm into smaller parts and organizing the pieces in an easy-to-understand fashion.
  • If your problem contains a lot of interaction between discrete objects (like characters and enemies in a game), Object-oriented programming will really shine. Object-oriented programming provides useful facilities to encapsulate the details of how the objects operate and let them interact with each other by only using well-defined interfaces.

It's good to remember that these paradigms are not mutually exclusive. You can use aspects of both object-oriented and functional programming at the same time. For example, OOP tells you how to structure an entire project, and how the components of that project will interact with each other. But, it doesn't have as much to say on how to structure the logic inside individual methods. You'll find many people writing methods in a more imperative style (similar to your original solution and parts of mine), and many other people reaching for concepts from functional programming to create their methods.

To answer the actual question here: the problem being presented is entirely algorithmic in nature, therefore, you'll find that the tools that functional programming provides will tend to do a good job of describing the problem in an elegant way. I would much prefer your functional version of the code over your procedural version (once it's cleaned up a bit. You have a couple of unused functions in there, etc).

If we are going to build this game using a traditional object-oriented way, what are the class we need to design here?

One of the biggest benefits of OOP is encapsulation. We hide away the implementation details of bits of logic into different classes and only refer to those logical chunks by the interfaces the classes provide. There really isn't a good way to split up the logic behind an algorithm between multiple classes - splitting up the algorithm that way would be unnatural, and much more difficult to understand.

If you're trying to do this in an OOP way, the most likely scenario is that you would already have classes, such as a class for the 4x4 grid, and this entire algorithm would simply be one function (and maybe some helpers) on that class. Your algorithm wouldn't get smeared across multiple classes. In other words, you'll have to look at the bigger picture of your project to figure out a good class structure. If we look at the algorithm alone, then we're too zoomed in to make any good decisions about what classes should exist.

I figured that we can use a 2-dimensional array to represent the board, or as one of the answers presented, we can use a 1-dimensional array to hold x,y coordinates, I wonder what are some of the pros and cons with either approach?

Maybe @Blindman67 will speak for themself as to why they preferred a one-dimensional array, so you can get a less-bias answer to this, but here are my thoughts: I will first note that it doesn't look like their array was holding x,y coordinates, rather, they were just smearing a 2d array into a 1d array, i.e. instead of storing [[4, 2], [16, 8]] they were storing [4, 2, 16, 8] and using some extra math to turn an x, y coordinate into a single index in that 1d array.

From the looks of it, this data structure was probably done because it made their particular approach to the algorithm easier. However, even that approach could have been done with a 2d-array, if their coordTransform functions returned a coordinate pair instead of a single index, and the consumers of the coordTransform functions were adapted appropriately. This approach could also have been chosen because there (might) be a slight speed improvement to it, but, please don't ever purposely uglify your code for negligible speed improvements and potentially extra buggy code. I would recommend just sticking with a 2d-array - it's a more natural data representation for this problem, it will be easier for other functions to consume this data structure, and it's easier for other programmers to understand as its a less tricky solution.

\$\endgroup\$
4
  • \$\begingroup\$ Hi thanks for the reply. Your solution definitely works. But I wonder why you said that "generate a new board" is better than mutating it. Like can you give me an example where mutating doesn't work? \$\endgroup\$
    – Joji
    Commented Mar 1, 2021 at 0:20
  • 1
    \$\begingroup\$ When I mentioned that, I was mostly referring to the general idea that it's often easier to follow what code is doing when its values don't mutate very often. This sentiment comes more from a functional mindset, but can be used in any paradigm. You can find plenty of content on the internet to learn more about this idea. As for an example where mutating doesn't work - both ways are equally powerful, though some things are easier in some styles. For example, I wanted to use .filter() to remove zeros, but .filter() doesn't mutate, so my code already had to be non-mutating to a degree. \$\endgroup\$ Commented Mar 2, 2021 at 6:17
  • \$\begingroup\$ hey thanks for the reply. as you said the original problem was more like an algorithmic question. However, if we were trying to build this game, including the logic to determine if the player wins or not, and the logic to spawn the game board, how would you go about designing the class? would you make one class for it or we need to make multiple classes for it? \$\endgroup\$
    – Joji
    Commented Mar 3, 2021 at 17:15
  • 1
    \$\begingroup\$ I feel like I need to open up a new question for it. could you please take a look at this codereview.stackexchange.com/questions/256691/… \$\endgroup\$
    – Joji
    Commented Mar 3, 2021 at 19:01
3
\$\begingroup\$

Review

Some review points on your code

  • Always delimit code blocks with {}. Eg if (target !== 0) startIndex++; is safer as if (target !== 0) { startIndex++; }

  • Avoid using continue (Its a goto in disguise). Rather the line if (array[endIndex] === 0) continue; is better as if (array[endIndex]) { /*... loop code ...*/ }. See rewrite.

  • Try to reduce array indexing by storing values in a variable. See rewrite

  • 0 is falsey so if (target !== 0) startIndex++; can be if (target) { startIndex++; } and if (target === 0 || can be if (!target ||

  • Variables that do not change should be constants. eg let target = array[start]; can be const target = array[start];

  • Try to keep names short. Long names make code harder to read

Rewrite

rewriting your function using the points above.


function mergeLeft(row) {
    var start = 0;
    for (let end = 1; end < row.length; end++) {
        const tile = row[end];
        if (tile) {
            const target = row[start];
            if (!target || target === tile ) {
                row[start] += tile ;
                row[end] = 0;
            } else if (start + 1 < end) {
                end--;
            }
            if (target) { start++; }
        }
    }
    return row;
}

Coordinate transform

Use a single dimension (1D) array to store the game board. The position on the board can be calculated from a coordinate pair. x = 2, y = 1 is 3rd column 2nd row. The index in a 1D array is index = x + y * size where size is the number of columns (4 in this case)

Your merge function moves from left to right, then down (I assume) to the next row and does the same. You can create an inner (columns) and outer (rows) loop, and calculate the board index from the coordinate pairs x,y


for (y = 0; y < 4; y ++) {
    for (x = 0; x < 4; x ++) {
        index = x + y * 4;
        // merge row
    }
}

The above steps over the arrays in the same direction as your mergeLeft function

To have it handle different directions you can define some functions that convert (transform) the x, y position to the correct index for the 4 directions. Up, Right, Left, and Down

For Example

const size = 4;                             // width and height of board
const tileCount = size * size;
const board = new Array(tileCount).fill(0); // Create the array of tiles

// named directions. The name of the key press
const moves = {
    up: 0,
    right: 1,
    down: 2,
    left: 3,
};

// functions that convert x,y position into board index
const coordTransform = [
    (x, y) => y + x * size,                // up from top down
    (x, y) => (size - 1) - x + y * size,   // right for right to left
    (x, y) => y + ((size - 1) - x) * size, // down from bottom up
    (x, y) => x + y * size,                // left from left to right
];

Before merging the rows we get the direction function const move = coordTransform[moves.up];

Then in the loop we use that function to transform the coordinates so that we step over items in the correct direction for the move. const idx = move(x, y); gets the transformed position.

Example using the above code to merge in any direction.

// The comments and naming is in terms of rows from left to right. move(moves.left)
// Call the function with the direction index eg move(moves.up);
function move(dir) {  
    // Tiles will stack to the left. 
    // left is the idx of the next left free pos
    // prev holds the prev left pos and is used to merge values if same        
    var x, y = 0, left, prev;

    // get the function to convert coord to board index
    const move = coordTransform[dir];  //  transform function for direction
    while (y < size) {
        left = x = 0;
        prev = -1;
        while (x < size) {
            const idx = move(x, y);  // Get the tile index using transform function
            const tile = board[idx]; // Get the tile value
            if (tile) {              // Does tile have a value
                board[idx] = 0;                  // Remove the tile from the board
                const idxLeft = move(left, y);   // Get next stack pos
                board[idxLeft] = tile;           // Set the tile value
                if (prev > -1) {                 // Is there a tile to left
                    if (tile === board[prev]) {  // Is tile to left the same
                        board[prev] += tile;     // Merge the tile values
                        board[idxLeft] = 0;      // Remove second tile, 
                        left--                   // Move index back 
                        prev = -1;               // Ensure only two merge at a time
                    } else { prev = idxLeft }    // If tile did not merge remember 
                } else { prev = idxLeft  }       //    position of tile
                left ++;                         // Move stack pos to next tile
            }
            x++;     // step to next column
        } 
        y++;         // step to next row.
    }
}

UPDATE Implementing the game.

The snippet is an implementation of the game without an in depth study of the original game.

It uses a partial OO approach

"If we are going to build this game using a traditional object-oriented way, what are the class we need to design here?"

Objects used

  • Note that JS uses Objects. You should not use the term class due to the significant differences between classes and objects in computer science.

  • I do not use the class syntax due to its very ill though out implementation (incomplete and a syntactical hack)

There is a factory function Game that encapsulates the game, with support code to add the UI for the particular environment (Code review snippet) placed outside Game.

The Game object uses closure to create private functions and properties. It also reduces the need to add the noisy this. and the even hackier this.#

Within Game is the Object Tile that uses the traditional OO JS prototype object style to implement the display and animation of tiles.

There are also various simple objects within Game

. states Holds enumerated game states. . moves Holds enumerated game actions (a move is a keyboard (swipe, or mouse, if implemented)) direction to move tiles . moveTransform is the transform used to convert 2D tile positions to 1D tile array positions depending on the move direction. (See details in first part of answer)

Outside the Game object

The keyboard interface uses a simple static object keys via an immediately invoked factory function (using closure to encapsulate private function) that ensures the will only be one instance of the keyboard interface.

Question 2

"I figured that we can use a 2-dimensional array to represent the board, or as one of the answers presented, we can use a 1-dimensional array to hold x,y coordinates, I wonder what are some of the pros and cons with either approach?"

The main reason is to reduce complexity. Rather than have to deal with coordinate pairs only a single value is needed to locate a tile. This reduces the complexity .

This game only has a small number of tiles 16 (though example allows up to 64 (it will handle many more but has be limited in the example to prevent poor performance on low end devices).

Many games use 2D layouts with 1000s of tiles, using 1D rather than 2D has many advantages

  • 1D arrays reduce the overhead associated with indexing arrays items

  • 1D arrays use less memory

  • 1D arrays can easily be converted to typed arrays without the need for complex instantiation code. On low end devices a typed array (as a shared array) allows logic in worker threads to share the data.

Question 3

"I was looking at it from an OOD approach and I was also aware that we can use functional programming instead. Here is my attempt"

Functional programming (FP) as a style in JS adds way too much processing and memory overheads to make it a practical for realtime games.

FP in JS has been adopted as a defense against poor encapsulation that is so common in JS.

Using a robust encapsulation style there is (in my view) no need to write in FP style in JS

The example below is written to fit the code review snippet and as such can not use modules.

Modules add an additional layer of encapsulation to JS code preventing side effects by creating privates scopes per module further reducing the need to use the FP style.

const eCurve = (v, p = 2) =>  v <= 0 ? 0 : v >= 1 ? 1 : v ** p / (v ** p + (1 - v) ** p);
const bellCurve = (v, p = 2) =>  v <= 0 ? 0 : v >= 1 ? 0 : (v *= 2, (v = v > 1 ? 2 - v : v),  v ** p / (v ** p + (1 - v) ** p));
const bCurve = (v, a, b) => v <= 0 ? 0 : v >= 1 ? 1 : 3*v*(1-v)*(a*(1-v)+b*v)+v*v*v; // cubic bezier curve
Math.PI90 = Math.PI / 2;
Math.TAU = Math.PI * 2;
Math.PI270 = Math.PI * (3 / 2);
const randUI = (min, max) => (max !== undefined ? Math.random() * (max - min) + min : Math.random() * min) | 0;
const randPick = arr => arr.splice(randUI(arr.length), 1)[0];
const randItem = arr => arr[randUI(arr.length)];
const tag = (tag, props = {}) => Object.assign(document.createElement(tag), props);

canvas.width = innerWidth;
canvas.height = innerHeight;
const ctx = canvas.getContext("2d");
const keys = (()=>{
    const keys = {
        ArrowLeft: false, 
        ArrowRight: false, 
        ArrowUp: false, 
        ArrowDown: false
    };
    document.addEventListener("keydown", keyEvent);
    document.addEventListener("keyup", keyEvent);
    function keyEvent(e) {
       if (keys[e.code] !== undefined) { keys[e.code] = e.type === "keydown"; e.preventDefault() }
    }
    return keys;
})();
var pageResized = false, inGame = false, instructionsKnown = 0, gameType = 1;
addEventListener("resize", () => { if (inGame) { pageResized = true } });
sel.addEventListener("click", (e) => {
  if (!inGame && e.target.dataset.game !== undefined) {
      gameType = e.target.dataset.game;
      sel.style.display = "none";
      playGame(gameType);
  }

});

const pathRoundBox = (ctx, x, y, w, h, r) => {
    const w2 = w / 2, h2 = h / 2;
    ctx.arc(x - w2 + r, y - h2 + r, r, Math.PI, Math.PI270);
    ctx.arc(x + w2 - r, y - h2 + r, r, Math.PI270, Math.TAU);
    ctx.arc(x + w2 - r, y + h2 - r, r, 0, Math.PI90);
    ctx.arc(x - w2 + r, y + h2 - r, r, Math.PI90, Math.PI);
}
function Game(size, winTile, speed = 20, tilesPerMove = 2) {
    var state;
    var moveCount = 0, displayDirty = true;
    const animRate = 1 / speed;
    const tileCount = size * size;
    const board = new Array(tileCount).fill(0);
    const mergingTiles = [];
    const freeTiles = [];
    const newTileVal = [];
    const newTileSet = [2,4,2,4,2,4,2,4,2,4,2,4];
    const states = {
        over: 1,
        inPlay: 2,
        animating: 3,
        win: 4,
    };
    state = states.inPlay;
    const moves = {
        up: 0,
        right: 1,
        down: 2,
        left: 3,
    };
    const moveTransform = [
        (x, y) => y + x * size,
        (x, y) => (size - 1) - x + y * size,
        (x, y) => y + ((size - 1) - x) * size,
        (x, y) => x + y * size,
    ];
    function Tile(val, idx, growDelay = 0) {
        this.nx = this.x = idx % size;
        this.ny = this.y = idx / size | 0;
        this.val = val;
        this.anim = 0;
        this.alpha = 1;
        this.animate = true;
        this.merging = false;
        this.grow = true;
        this.size = -growDelay;
        this.flash = false;
        this.flashAlpha = 0;
    }
    Tile.prototype = {
        newPos(idx) {
            this.nx = idx % size;
            this.ny = idx / size | 0;
            this.ax = this.x;
            this.ay = this.y;
            this.anim = 1;
            this.animate = true;
        },
        merge(idx) {
            this.newPos(idx);
            this.merging = true;
            return this;
        },
        mergeFlash(idx) {
            this.animate = true;
            this.flash = true;
            this.flashAlpha = 1;
            this.val *= 2;
            return this;
        },
        update() {
            if (this.merging) {
                this.alpha -= animRate;
                if (this.alpha <= 0) { 
                    this.alpha = 0;
                    this.merging = false;
                }   
            }
            if (this.anim > 0) {
                this.anim -= animRate;
                if (this.anim <= 0) { this.anim = 0 }
            }
            if (this.grow) {
                this.size += animRate;
                if (this.size > 1) {
                    this.size = 1;
                    this.grow = false;
                }
            }
            if (this.flash) {
                this.flashAlpha -= animRate * 2;
                if (this.flashAlpha < 0) {
                    this.flashAlpha = 0;
                    this.flash = false;
                }
            }
            this.animate =  this.flash || this.grow || this.merging || this.anim > 0;
            displayDirty = this.animate ? true : displayDirty;
            if (this.anim > 0) { 
                const p = eCurve(1 - this.anim);
                this.x = (this.nx - this.ax) * p + this.ax;
                this.y = (this.ny - this.ay) * p + this.ay;
            } else {
                this.x = this.nx;
                this.y = this.ny;
            }
        },
        render(target) {
            const {ctx, scale, radius, cols, textCol, pad} = target;
            ctx.save()
            const hS = scale / 2;
            var x = this.x * scale;
            var y = this.y * scale;
            const inScale = this.flash ?  1 + bellCurve(this.flashAlpha) * 0.1 : bCurve(this.size, 0, 1.6);
            ctx.transform(inScale, 0, 0 , inScale, x + hS, y + hS);
            ctx.globalAlpha = eCurve(this.alpha);
            ctx.fillStyle = cols[this.val];
            ctx.beginPath();
            pathRoundBox(ctx, 0, 0, scale - pad * 2, scale - pad * 2, radius);
            ctx.fill();
            if (this.flash) {
                ctx.globalCompositeOperation = "lighter";
                ctx.globalAlpha = eCurve(this.flashAlpha);
                ctx.fillStyle = "#FFF";
                ctx.fill();
                ctx.globalCompositeOperation = "source-over";
                ctx.globalAlpha = 1;
            }
            ctx.fillStyle = textCol;
            ctx.fillText(this.val, 0, 0 + scale * 0.05);
            ctx.restore()
        }
    }
    function addTile(growDelay) {
        const pos = randPick(freeTiles);
        if (newTileVal.length === 0) { newTileVal.push(...newTileSet) }
        board[pos] = new Tile(randPick(newTileVal), pos, growDelay);
    }
    function checkBoard() {
        var i = tileCount, s = state;
        freeTiles.length = 0;
        while (i--) {
            const t = board[i];
            if (t?.val === winTile) {
                s = states.win;
            } else if (t === 0) {
                freeTiles.push(i);
            }
        }
        if (freeTiles.length < tilesPerMove && s !== states.win) { s = states.over }
        API.state = s;
    }
    function move(dir) {
        moveCount ++;
        var x, y = 0, f, prev;
        const move = moveTransform[dir];
        while (y < size) {
            f = x = 0;
            prev = -1;
            while (x < size) {
                const idx = move(x, y);
                const tile = board[idx];
                board[idx] = 0;
                if (tile) {
                    const idxf = move(f, y);
                    board[idxf] = tile;
                    idxf !== idx && tile.newPos(idxf);
                    if (prev > -1) {
                        if (tile.val === board[prev].val) {
                            board[prev].mergeFlash();
                            mergingTiles.push(tile.merge(prev));
                            board[idxf] = 0;
                            f--;
                            prev = -1;
                        } else { prev = idxf }
                    } else { prev = idxf }
                    f++;
                }
                x++;
            }
            y++;
        }
    }
    const API = {
        states: Object.freeze(states),
        moves: Object.freeze(moves),
        get displayDirty() {
            if (state === states.animating) { displayDirty = true }
            const res = displayDirty;
            displayDirty = false;
            return res;
        },
        get moveCount() { return moveCount },
        get state() { return state },
        set state(s) {
            if (s !== state) {
                if (state === states.animating && s === states.inPlay) {
                    state = s;
                    checkBoard();
                    if (state === states.inPlay) {
                        let i = 0
                        while (i < tilesPerMove) { addTile(i++ / 2) }
                        displayDirty = true;
                    }
                } else {
                    state = s;
                    displayDirty = true;
                }
            }
        },
        set move(dir) {
            if (state === states.inPlay) {
                if (dir >= 0 && dir < 4) {
                    move(dir);
                    API.state = states.animating;
                }
            }
        },
        reset() {
            board.fill(0);
            API.state = states.animating;
            moveCount = 0;
            checkBoard();
        },
        render(target) {
            var i = 0, tail = 0, animating = false;
            board.forEach(tile => {
                if (tile !== 0) {
                    tile.animate && (animating = true, tile.update());
                    tile.render(target);
                }
            });
            while (i < mergingTiles.length) {
                const tile = mergingTiles[i];
                tile.update();
                tile.render(target);
                if (tile.animate) {
                    animating = true
                    mergingTiles[tail++] = tile
                }
                i++;
            }
            mergingTiles.length = tail;
            !animating && state === states.animating && (API.state = states.inPlay);
        },
    }
    return Object.freeze(API);
}


function createBGImage(img, tiles, pad, radius) {
    const ctx = img.getContext("2d");
    ctx.fillStyle = "#222";
    ctx.beginPath();
    pathRoundBox(ctx, img.width / 2, img.height / 2, img.width, img.height, radius + pad * 2);
    ctx.fill();
    var i = 0;
    const size = img.width / tiles;
    ctx.fillStyle = "#666";
    while (i < tiles * tiles) {
        const x = (i % tiles) + 0.5;
        const y = (i / tiles | 0)  + 0.5;
        ctx.beginPath();
        pathRoundBox(ctx, x * size, y * size, size - pad * 2, size - pad * 2, radius);
        ctx.fill();
        i++;
    }
    return img;
}   


function playGame(game = gameType) {
    inGame = true;
    const GAMES = [
       [4, 512,  20, 2],
       [5, 2048, 20, 2],
       [6, 2048, 20, 2],
       [7, 1024, 20, 3],
       [8, 2048, 16, 4],
    ];
    const SIZE = GAMES[game][0];
    const g = Game(...GAMES[game]);
    g.reset();
    ctx.canvas.width = innerWidth;
    ctx.canvas.height = innerHeight;
    requestAnimationFrame(animLoop);
    const minRes =  Math.min(ctx.canvas.width, ctx.canvas.height);
    const unit =  minRes / 64;
    const RADIUS = minRes / SIZE / 8, PAD = minRes / SIZE / 16;
    const bgImage = createBGImage(tag("canvas", {width: minRes, height: minRes}), SIZE, PAD / 2, RADIUS + PAD / 2);
    const renderTarget = {
        ctx,
        scale:  minRes / SIZE,
        radius: RADIUS,
        pad:    PAD,
        textCol: "#000",
        cols: {
            [2]:    "#F40",
            [4]:    "#F80",
            [8]:    "#FA0",
            [16]:   "#FC0",
            [32]:   "#FF0",
            [64]:   "#CF0",
            [128]:  "#8F0",
            [256]:  "#0F0",
            [512]:  "#0F8",
            [1024]: "#0FF",
            [2048]: "#FFF",
        }    
    }
    ctx.font = (renderTarget.scale * 0.7 | 0) + "px Arial";
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    function animLoop() {
        var m = -1;

        // Rather than dynamic resize this just restarts on resize
        if (pageResized) {
            pageResized = false;
            setTimeout(playGame, 0);
            inGame = false;
            return;
        }
        if (keys.ArrowUp) { keys.ArrowUp = false; m = g.moves.up }
        if (keys.ArrowRight) { keys.ArrowRight = false; m = g.moves.right }
        if (keys.ArrowDown) { keys.ArrowDown = false; m = g.moves.down }
        if (keys.ArrowLeft) { keys.ArrowLeft = false; m = g.moves.left }
        
        if (m > -1) { 
            g.move = m; 
            instructionsKnown++;
        }
        const state = g.state;
        if (g.displayDirty) {
            ctx.textAlign = "center";
            ctx.textBaseline = "middle";
            ctx.setTransform(1,0,0,1,0,0);
            ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
            ctx.font = (renderTarget.scale * 0.45 | 0) + "px Arial";
            ctx.setTransform(1,0,0,1,(canvas.width - SIZE * renderTarget.scale) / 2, 0);
            ctx.drawImage(bgImage, 0, 0);
            g.render(renderTarget);
            if (!(state === g.states.inPlay || state === g.states.animating)) {
                ctx.fillStyle = "#0008";
                ctx.setTransform(1,0,0,1,0,0);
                ctx.fillRect(0, 0, ctx.canvas.width,  ctx.canvas.height);
                ctx.font = (renderTarget.scale * 0.7 | 0) + "px Arial";
                ctx.textAlign = "center";
                ctx.textBaseline = "middle";        
                ctx.fillStyle = "#FFF";
                ctx.strokeStyle = "#000";
                ctx.lineWidth = unit * 2;
                ctx.lineJoin = "round";
                const cx =  ctx.canvas.width * 0.5;
                const cy =  ctx.canvas.height * 0.3
                if (state === g.states.win) {
                    ctx.strokeText("WINNER!", cx + unit / 2, cy + unit / 2);
                    ctx.fillText("WINNER!",cx, cy)
                    ctx.strokeStyle = "#FED";
                    ctx.lineWidth = unit / 2;
                    ctx.lineJoin = "round";
                    ctx.strokeText("WINNER!", cx, cy)
                    ctx.font = (renderTarget.scale * 0.4 | 0) + "px Arial";
                    ctx.fillStyle = "#FFF";
                    ctx.strokeStyle = "#000";
                    ctx.lineWidth = unit;
                    const mC = g.moveCount;
                    ctx.strokeText("Moves: " + mC, cx + unit / 4, cy + unit * SIZE * 3 + unit / 4);
                    ctx.fillText("Moves: " + mC, cx, cy + unit * SIZE * 3);
                    
                } else {
                    ctx.strokeText("GAME OVER", cx + unit / 2, cy + unit / 2);
                    ctx.fillText("GAME OVER", cx, cy)
                    ctx.strokeStyle = "#DEF";
                    ctx.lineWidth = unit  / 2;
                    ctx.lineJoin = "round";
                    ctx.strokeText("GAME OVER", cx, cy);
                }

                inGame = false;
                return;
            } 
            if (instructionsKnown < 2) {
                ctx.setTransform(1,0,0,1,0,0);
                ctx.fillStyle = "#FFF";
                ctx.font = "16px Arial";
                ctx.fillText("Use arrow keys to play", ctx.canvas.width / 2, 20);
            }            
        }
        requestAnimationFrame(animLoop);
    }
}
canvas {
   position: absolute;
   top: 0px;
   left: 0px;
   pointer-events: none;
}
#sel {
  font-family: arial;
  display: flex;
}
.bt {
   flex-wrap: row;
   cursor: pointer;
   width: 50px;
   text-align: center;
   border: 1px solid black;
   margin: 4px;
   padding-top: 3px;
}
.bt:hover {
   background: #D92;
}
.txt {
  margin-top: 8px;
}
<canvas id="canvas"></canvas>
<div id="sel">
   <div class="txt">Select grid size:</div>
   <div class="bt" data-game="0">4 by 4</div>
   <div class="bt" data-game="1">5 by 5</div>
   <div class="bt" data-game="2">6 by 6</div>
   <div class="bt" data-game="3">7 by 7</div>
   <div class="bt" data-game="4">8 by 8</div>
</div>

Note Code review pages use a lot of JS and as such impact the performance of snippets. To get a true feel of how the game plays copy the code to a stand alone page..

Note as there is a limit to code review answer size the above was coded to fit and as such lost a lot of code and reduced naming length to fit.

\$\endgroup\$
5
  • \$\begingroup\$ Hey thanks for the reply. I understand that JS uses objects but it doesn't prevent people from thinking in terms of OO I think. Granted JS's class is not exactly the same as Java or C++'s class, but I think it is still possible to implement the OO paradigm in JS. I am very curious about this particular advantage of using 1d array - "1D arrays can easily be converted to typed arrays without the need for complex instantiation code. " Could you elaborate more on this? I guess by typed array you meant something like "a float32array to allocate a fixed-length contiguous memory area" right? \$\endgroup\$
    – Joji
    Commented Mar 2, 2021 at 19:51
  • \$\begingroup\$ @Joji Yes Float32Array is one if the typed arrays. Fixed length and only 1D. Typed arrays can be transferred to webworkers using zero copy transfer and can be shared between threads via shared array. I did not say OO was not possible in JS just that the convention is not to call objects classes \$\endgroup\$
    – Blindman67
    Commented Mar 2, 2021 at 20:15
  • \$\begingroup\$ Thanks for the reply. maybe I am just not familiar with web worker. do we have to convert normal arrays to typed arrays if we need to transfer them from the main thread to any worker thread? \$\endgroup\$
    – Joji
    Commented Mar 2, 2021 at 20:22
  • \$\begingroup\$ @Joji Normal arrays need to be copied, You can not use zero copy transfer on normal arrays \$\endgroup\$
    – Blindman67
    Commented Mar 2, 2021 at 20:26
  • \$\begingroup\$ +1 for the full-fledged game, that was great :) \$\endgroup\$ Commented Mar 3, 2021 at 3:50

Not the answer you're looking for? Browse other questions tagged or ask your own question.