6
\$\begingroup\$

I am creating Tetris in javascript. I have finished the basic mechanics. I would appreciate some feedback on how I could improve it.
Down, left, and right arrow keys move the piece while the up arrow key rotates it. It works best in full page mode.

"use strict";

const pieceData = [
  { // O
    builds: [
      [
        [0, 0],
        [1, 0],
        [0, 1],
        [1, 1],
      ],
    ],
    type: "other",
  },

  { // T
    builds: [
      [
        [0, 1],
        [1, 1],
        [1, 2],
        [2, 1],
      ],
      [
        [1, 0],
        [1, 1],
        [2, 1],
        [1, 2],
      ],
      [
        [1, 0],
        [0, 1],
        [1, 1],
        [2, 1],
      ],
      [
        [1, 0],
        [1, 1],
        [1, 2],
        [0, 1],
      ],
    ],
    type: "other",
  },
  { // long bar
    builds: [
      [
        [0, 0],
        [1, 0],
        [2, 0],
        [3, 0],
      ],      
      [
        [1, -1],
        [1, 0],
        [1, 1],
        [1, 2],
      ], 
    ],
    type: "other",
  },
  { // Z
    builds: [
      [
        [0, 0],
        [1, 0],
        [1, 1],
        [2, 1],
      ],
      [
        [1, -1],
        [1, 0],
        [0, 0],
        [0, 1],
      ],
    ],
    type: "left",
  },
  { // S
    builds: [
      [
        [1, 0],
        [0, 0],
        [0, 1],
        [-1, 1],
      ],
      [
        [0, -1],
        [0, 0],
        [1, 0],
        [1, 1],
      ],
    ],
    type: "right",
  },
  { // L
    builds: [
      [ 
        [0, 0],
        [0, 1],
        [0, 2],
        [1, 2],
      ],
      [
        [-1, 1],
        [0, 1],
        [1, 1],
        [1, 0],
      ],
      [
        [-1, 0],
        [0, 0],
        [0, 1],
        [0, 2],
      ],
      [
        [-1, 2],
        [-1, 1],
        [0, 1],
        [1, 1],
      ],
    ],
    type: "right",
  },
  { // J
    builds: [
      [
        [1, 0],
        [1, 1],
        [1, 2],
        [0, 2],
      ],
      [
        [0, 0],
        [0, 1],
        [1, 1],
        [2, 1],
      ],
      [
        [1, 0],
        [0, 0],
        [0, 1],
        [0, 2],
      ],
      [
        [-1, 1],
        [0, 1],
        [1, 1],
        [1, 2],
      ]
    ],
    type: "left",
  },
];


const canvas = document.getElementById("display");
const nextPieceDisplay = document.getElementById("next");
const npctx = nextPieceDisplay.getContext("2d");
const ctx = canvas.getContext("2d");
const width = canvas.width;
const height = canvas.height;
const npw = nextPieceDisplay.width;
const nph = nextPieceDisplay.height;
const colors = {
  "left": "#118c8b",
  "right": "#f14d49",
  "other": "#bca18d",
}

class Block {
  constructor(x, y, size, type) {
    this.x = x;
    this.y = y;
    this.size = size;
    this.type = type;
  }

  draw(loc) {
    loc.fillStyle = colors[this.type];
    loc.fillRect(this.x, this.y, this.size, this.size);
    loc.strokeRect(this.x, this.y, this.size, this.size);
  }
}

class Piece {
  constructor(x, y, size, type, builds) {
    this.x = x;
    this.y = y;
    this.size = size;
    this.type = type;
    this.builds = builds;
    this.blocks = [];
    this.current = randInt(0, this.builds.length);
    this.swap(0);
  }

  draw(loc) {
    this.blocks.forEach(block => {
      block.draw(loc);
    });
  }

  swap(dir) {
    this.current += dir;
    this.current = mod(this.current, this.builds.length);
    let newBuild = [];
    for (let i = 0; i < this.builds[this.current].length; ++i) {
      let pos = this.builds[this.current][i];
      newBuild.push(new Block(this.x + pos[0] * this.size, this.y + pos[1] * this.size, this.size, this.type));
    }
    this.blocks = newBuild;
  }

  move(x, y) {
    this.x += x * this.size;
    this.y += y * this.size;
    this.blocks.forEach(block => {
      block.x += x * this.size;
      block.y += y * this.size;
    });
  }
}

class Board {
  constructor(w, h, gridSize) {
    this.active = undefined;
    this.blocks = [];
    this.grid = gridSize;
    this.w = w;
    this.h = h;
    this.shouldLock = false;
    this.score = 0;
    this.level = 0;
    this.nextPieceId = randInt(0, pieceData.length);
  }

  draw() {
    ctx.clearRect(0, 0, this.w, this.h);
    this.active.draw(ctx);
    this.blocks.forEach(block => {
      block.draw(ctx);
    });
  }

  newPiece() {
    let data = pieceData[this.nextPieceId];
    this.active = new Piece(Math.round(this.w / 2) - this.grid, 0, this.grid, data.type, data.builds);
    if (this.pieceBlocked(0, 0)) {
      clearInterval(gameLoop);
      alert(`Final score: ${this.score}`);
      location.reload();
    }
    this.nextPieceId = randInt(0, pieceData.length);
    let tempData = pieceData[this.nextPieceId];
    let tempPiece = new Piece(50, 50, 25, tempData.type, tempData.builds);
    npctx.clearRect(0, 0, npw, nph);
    tempPiece.draw(npctx);
  }

  findXs(y) {
    let xs = [];
    for (let i = 0; i < this.blocks.length; ++i) {
      if (this.blocks[i].y === y) {
        xs.push(this.blocks[i].x);
      }
    }
    return xs;
  }

  next() {
    this.active.swap(1);
    if (this.pieceBlocked(0, 0)) {
      this.active.swap(-1);
    }
    else {
      this.shouldLock = false;
    }
  }

  findFulls() {
    let fulls = [];
    for (let y = 0; y < height; y += this.grid) {
      let xs = this.findXs(y);
      if (xs.length >= this.w / this.grid) {
        fulls.push(y);
      }
    }
    return fulls;
  }

  shift(ys) {
    if (ys.length) {
      let grid = this.grid;
      this.blocks.forEach(block => {
        let below = ys.filter(a => a > block.y);
        block.y += below.length * grid;
      });
    }
  }

  update() {
    let noFall = this.pieceBlocked(0, 1);
    if (this.shouldLock) {
      if (noFall) {
        this.lock();
        this.newPiece();
        return;
      }
      this.shouldLock = false;
    }
    if (noFall) {
      this.shouldLock = true;
    }
    else {
      this.active.move(0, 1);
    }
    let fulls = this.findFulls();
    for (let i = 0; i < this.blocks.length; ++i) {
      if (fulls.includes(this.blocks[i].y)) {
        this.blocks[i] = undefined;
      }
    }
    let len = fulls.length;
    this.blocks = this.blocks.filter(a => a);
    this.shift(fulls);
    if (len) {
      this.score += len ** 2 * 100;
    }
    this.level = Math.floor(Math.sqrt(this.score / 1000));
    document.getElementById("score").innerText = "Score: " + this.score.toString();
    document.getElementById("level").innerText = "Level: " + this.level.toString();
    this.draw();
  }

  handleMovement(x, y) {
    if (!this.pieceBlocked(x, y)) {
      this.active.move(x, y);
      if (x === 0 && y === 1) {
        this.score++;
        this.shouldLock = true;
      }
    }
  }

  blocked(block, xVel, yVel) {
    let x = block.x + xVel * this.grid;
    let y = block.y + yVel * this.grid;
    if (x < 0 || x >= this.w || y >= this.h) {
      return true;
    }
    for (let i = 0; i < this.blocks.length; ++i) {
      if (x == this.blocks[i].x && y == this.blocks[i].y) {
        return true;
      }
    }
  }

  pieceBlocked(xVel, yVel) {
    for (let i = 0; i < this.active.blocks.length; ++i) {
      if (this.blocked(this.active.blocks[i], xVel, yVel)) {
        return true;
      }
    }
    return false;
  }

  lock() {
    this.blocks = this.blocks.concat(this.active.blocks);
    this.active = undefined;
    this.score += 10;
  }

  move(x, y) {
    if (!this.pieceBlocked(x, y)) {
      this.active.move(x, y);
    }
  }
}

function mod(n, m) {
  return ((n % m) + m) % m;
}

function randChoice(arr) {
  let rand = randInt(0, arr.length);
  return arr[rand];
}

function randInt(min, max) {
  return Math.floor(Math.random() * (max - min)) + min;
}



const board = new Board(width, height, 25);
let lastLevel = 0;
board.newPiece();

function game() {
  board.update();
  if (board.level != lastLevel) {
    clearInterval(gameLoop);
    lastLevel = board.level;
    gameLoop = setInterval(game, (1 / lastLevel) * 500)
  }
}

let gameLoop = setInterval(game, 500);

addEventListener("keydown", e => {
  switch (e.code) {
    case "ArrowUp":
      board.next();
      break;
    case "ArrowDown":
      board.handleMovement(0, 1);
      break;
    case "ArrowLeft":
      board.handleMovement(-1, 0);
      break;
    case "ArrowRight":
      board.handleMovement(1, 0);
      break;
  }
  board.draw();
});
html {
  height: 100%;
  display: grid;
}

body {
  margin: auto;
}

#display {
  border: 3px solid black;
  float: left;
}

#info {
  float: right;
  margin: 10px;
}
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
    <title>Tetris</title>
  </head>
  <body>
    <canvas id="display" width="250" height="450"></canvas>
    <div id="info">
      <p id="score">Score: 0</p>
      <p id="level">Level: 0</p>
      <p>Next piece: </p>
      <canvas id="next" width="200" height="200"></canvas>
    </div>
  </body>
</html>

\$\endgroup\$
  • \$\begingroup\$ Your blocks are not realy random \$\endgroup\$ – H. Pauwelyn May 23 at 18:10
  • \$\begingroup\$ @H.Pauwelyn Why not? \$\endgroup\$ – All Knower May 23 at 19:12
  • \$\begingroup\$ I've everythime the same 2 or 3 blocks on Google Chrome 74 for Android KitKat. \$\endgroup\$ – H. Pauwelyn May 24 at 5:19
  • \$\begingroup\$ @H.Pauwelyn It may not feel very random at times but I'm pretty sure it's as random as I can get it. \$\endgroup\$ – All Knower May 24 at 14:57
1
\$\begingroup\$

General points

  • Move you constants and variable declarations to the top of the code. Keeping it all together make managing the development easier.

  • Game constants should be UPPER_CASE_SNAKE and don't put property names inside quotes

eg

const COLORS = {
  left: "#118c8b",
  right: "#f14d49",
  other: "#bca18d",
}
  • The class syntax may seem convenient but you can build objects that encapsulate their working properties in such a way to avoid the need for the messy and semantically ambiguous this token (You have around 120 this tokens in your code)

Example almost identical to your Block class but not a this in sight.

function Block(x, y, size, type = "other") {
    const FILL_STYLE = COLORS[type];
    return {
        get x() { return x },
        get y() { return y },
        draw(ctx) {
            ctx.fillStyle = FILL_STYLE;
            ctx.fillRect(x, y, size, size);
            ctx.strokeRect(x, y, size, size);
        },
        move(addX, addY) {
            x += addX * size;
            y += addY * size;
        },
    };
}

const b = new Block(x, y, size, type);  // instantiate
// or you no longer need the new token
const b = Block(x, y, size, type);
b.move(1, 0);          // move right
filter(a => a < b.y);  // access getter
  • The way you define the shapes is a little over cooked and could be simplified with some additional code. Part of making a good game is being able to tune the game so ensuring that content is easily created and modified goes a long way to creating an engaging game.

For example the following snippet build the pieces from strings

const O_PIECE = "####";
const T_PIECE = "### # ";
const BAR_PIECE = "####";            
const Z_PIECE = " # ###";
const S_PIECE = " #### ";

const pieceData = [
  createPiece(O_PIECE,2),
  createPiece(T_PIECE,3),
  createPiece(S_PIECE,3),
  createPiece(Z_PIECE,2),
  createPiece(BAR_PIECE,4),      
];

function createPiece(layout, width, type = "other") {
    const height = layout.length / width;
    var i = layout.length;
    const build = []
    while(i--) { 
        if(layout[i] === "#") {
            const x = i % width;
            const y = i / width | 0;
            build.push([x, y]);
        }
    }
    return {
        builds: [build],
        type,
    };
}

Build a resource

Put your utility code together so that you can build a library of these as you develop other apps.

const gMath = {};  // g for game maths
gMath.mod = (n, m) => (n % m + m) % m;
gMath.randInt = (min, max = min + (min = 0)) => Math.floor(Math.random() * (max - min) + min);
gMath.randItem = arr => arr[gMath.randInt(arr.length)];

Rendering and timing

Though this game does not require a high frame rate it is still best to have your rendering synced with the display. Using requestAnimationFrame you can create a rendering loop that will run at 60fps (Frames per second)

To control the game render rate you use a frame counter and a render rate so that your game renders ever n frames

requestAnimationFrame(mainLoop); // will start the animation loop when execution is idle
var frameCounter = 0;
var gameSpeed = 30; // Render every 30 frame or 1/2 second
var updateRender = true; // when true will re-render the game
function mainLoop(time) { // time is a high resolution time supplied by the DOM

    // call IO code here

    if (frameCounter % gameSpeed === 0) {
        updateGame(); // call the game update
        updateRender = true;
    }

    if (updateRender) {
        updateRender = false;
        draw(); // draw the game
    }


    frameCounter ++;
    requestAnimationFrame(mainLoop);
}

Decouple the IO

Try to keep the IO code separate from the game logic. Use IO events like keyboard and mouse/touch to set the state of an IO object. Use the IO objects interface to control the game

Example. The key states IO.keys. are true while the key is down and false if not. They remove the automatic repeating.

const IO = {  
    keys: { // list only the keys you need
        ArrowLeft: false, 
        ArrowRight: false,
        ArrowDown: false,
        Escape: false,  // reset or end game
    },
    clear() {
        for(const key of Object.keys(IO.keys) { IO.keys[key] = false }
    },
    keyboard(event) {
        if (IO.keys[event.keyCode] !== undefined) {
            IO.keys[event.keyCode] = event.type === "keydown";
            event.preventDefault();
        }
    }
}
addEventListener("keydown", IO.keyboard);
addEventListener("keyup", IO.keyboard);

To use bind IO state to game actions so that you can reconfigure the IO without needing to go into the game code. Also allows you to bind additional IO types (like tap or click) to game actions.

const GAME_DOWN = "ArrowDown"; // bind key to action
const GAME_LEFT = "ArrowLeft"; // bind key to action
const GAME_RIGHT = "ArrowRight"; // bind key to action
const KEY_REPEAT_RATE = 20; // every 20 frames 5 times a second
var rightRepeat = 0;
function doInput() {
    // Action while key is down
    if (IO.keys[GAME_LEFT]) {
        /* do stuff */
    }

    // Action only once per key down
    if (IO.keys[GAME_DOWN]) {
        IO.keys[GAME_DOWN] = false; // turn off key. 
        /* do stuff */
    }

    // Action repeats on key down
    if (IO.keys[GAME_RIGHT]) {
        if (rightRepeat === 0) {
            rightRepeat = keyRepeatRate;                
            /* do stuff */
        }
        rightRepeat--; // count down to next repeat
    } else {
        rightRepeat = 0; // reset repeat
    }

    // when changing levels clear the input state so if the user
    // is holding a key down it does not effect the new level or game
    IO.clear();
}

Don't forget that many devices are touch and do not have a keyboard.

Encapsulate

When it comes time to show off your game you will need it to be easily inserted into 3rd party pages.

To ensure there are no problems encapsulate the whole game so that it does not have content in the global scope. This can be done as a function that calls its self. IIF (immediately invoked function)

;((container)=> {
    "use strict";
    /* 
       All game code in here

    */
})(document.body);  // the container for your game

Part of the encapsulation is DOM content as well, you can not rely on the site hosting your game to supply content so try to make the game self reliant (Can run without linking to a site you host to supply content)

const gDOM = {}; // g for game DOM 
gDOM.create = (tag, props = {}) => Object.assign(document.createElement(tag), props);
gDOM.append = (parent, ...children) => children.reduce((p, c) => (p.appendChild(c), p), parent);

Usage

const styles = `
    tetris--score-display {
        /* add style stuff
    }
    /* and so on */
`;
gDOM.append(container, gDOM.create("style", {innerHTML: styles}));
const canvas = gDOM.create("canvas", {width: gameWidth, height: gameHeight});
const block = gDOM.create("canvas", {width: blockWidth, height: blockHeight}); 
const score = gDOM.create("div", {textContent: "Score:", className: "tetris--score-display"}); 
const level = gDOM.create("div", {textContent: "Level:", className: "tetris--level-display"}); 

const ctx = canvas.getContext("2d");
const bctx = block.getContext("2d");

gDOM.append(container, canvas, block, score, level);

// To set score or level
score.innerText = "Score: " + this.score;  // Note you had this.score.toString() the toString is autonomic
level.innerText = "Level: " + this.level;

Responsive

Make your game responsive to the space available. Fill as much of the space you get as possible, you don't want your game to be a tiny box on a 4K screen and worse you don't want your game to go outside the available view-able area.

Don't worry about performance as high res devices come with high end GPUs, but always set the canvas resolution to match the displayed size so you don't over cook the GPU.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

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