<h1>Backbone Tetris</h1>
<p>Implementation of Tetris in backbone.js</p>
<div id="controls">
  <button id="pause">Pause (p)</button>
  <button id="mute">Mute (m)</button>
  <button id="new_game">New Game (n)</button>
</div>
<div id="board">
  <canvas width="300" height="500"></canvas>
  <div id="gridContainer">
    <div class="grid"></div>
  </div>
  <div id="message"></div>
</div>
body {
  font-family: 'Lato', sans-serif;
  text-align: center;
  background: #000;
  color: white;
}

#controls {
  margin-bottom: 10px;
  color: black;
}

#board {
  margin: 0 auto;
  overflow: auto;
  width: 624px;
  position: relative;
}

#message {
  position: absolute;
  color: white;
  font-size: 20px;
  font-weight: bold;
  left: 50%;
  top: 140px;
}

canvas {
  border: 1px solid white;
  float: left;
}

#gridContainer {
  float: left;
  width: 260px;
  border: 1px solid white;
  padding: 20px;
  margin-left: 20px;
  color: #565656;
}

.gridOff {
  color: #787878;
}

.gridOn {
  color: red;
}
var app = app || {};

app.Block = Backbone.Model.extend({

  defaults: {
    x: 0,
    y: 0
  },

  initialize: function() {
    var shape = this.get('shape');
    this.set('width', shape[0].length);
    this.set('height', shape.length);
  }

});

app.Board = Backbone.Collection.extend({

  model: app.Block

});

app.BlockView = Backbone.View.extend({

  initialize: function() {
    var self = this;
    this.render();
    this.model.on('change', _.bind(this.render, this));
  },

  render: function() {
    this.clearCanvas();
    var self = this;
    var shape = this.model.get('shape');
    var x_pos = this.model.get('x') * app.blockSize;
    var y_pos = this.model.get('y') * app.blockSize;
    for (var y = 0; y < shape.length; y++) {
      var row = shape[y];
      for (var x = 0; x < row.length; x++) {
        if (row[x] == 1) {
          self.stamp(x_pos + (x * app.blockSize), y_pos + (y * app.blockSize));
        }
      }
    }
    app.events.trigger('blockRendered');
    return this;
  },

  clearCanvas: function() {
    app.context.clearRect(0, 0, app.canvas.width, app.canvas.height);

  },

  stamp: function(x, y) {
    app.context.beginPath();
    app.context.rect(x, y, app.blockSize, app.blockSize);
    app.context.lineWidth = 1;
    app.context.strokeStyle = 'white';
    app.context.stroke();
  }

});

app.BoardView = Backbone.View.extend({

  el: $('canvas'),

  paused: false,

  muted: false,

  gameOver: false,

  /* Initialize board view */
  initialize: function() {
    gridView = new app.GridView();
    if (!app.logging) {
      var gridWidth = $('#gridContainer').width();
      var boardWidth = $('#board').width();
      $('#gridContainer').hide();
      $('#board').width(boardWidth - gridWidth - 60);
    }
    this.collection = new app.Board(app.blocks);
    gridView.clearGrid();
    gridView.logGrid();
    $(document).on('keydown', $.proxy(this.keyAction, this)); // http://stackoverflow.com/a/13379556/519497
    app.events.on('pause', this.pause, this);
    app.events.on('mute', this.mute, this);
    app.events.on('blockRendered', gridView.drawGrid, this);
    this.start();
  },

  /* Set up interval */
  start: function() {
    var self = this;
    clearInterval(app.interval);
    app.interval = setInterval(function() {
      self.descend();
      self.render();
    }, 800);
  },

  /* Render board */
  render: function() {
    this.collection.each(function(model) {
      var blockView = new app.BlockView({
        model: model
      });
    }, this);
  },

  /* Behaviour for when a block has landed */
  blockLanded: function(block) {
    this.updateGrid();
    this.checkGameOver(block);
    this.checkCompleteRows();
    this.clearCollection();
    this.spawnNewBlock();
  },

  /* Clear colletion of current block */
  clearCollection: function() {
    this.collection.reset();
  },

  /* Create a new block at random and add to collection */
  spawnNewBlock: function() {
    var shapePos = _.random(app.shapes.length - 1);
    this.collection.add([app.shapes[shapePos]]);
  },

  /* Dispatch key commands */
  keyAction: function(e) {
    var code = e.keyCode || e.which;
    if (!this.paused) {
      if (code == 37) {
        this.moveLeft();
      } else if (code == 39) {
        this.moveRight();
      } else if (code == 40) {
        this.moveDown();
      } else if (code == 38) {
        this.rotate();
      }
    }
    if (code == 80) {
      this.pause();
    }
    if (code == 78) {
      this.newGame();
    }
    if (code == 77) {
      this.mute();
    }
  },

  /* Pause or unpause game */
  pause: function() {
    this.paused = !this.paused;
    if (this.paused) {
      $('#pause').html('Unpause (p)');
      $('#message').html('Paused.')
      clearInterval(app.interval);
    } else {
      $('#pause').html('Pause (p)');
      $('#message').html('');
      this.start();
    }
  },

  /* Toggle mute */
  mute: function() {
    this.muted = !this.muted;
    if (this.muted) {
      $('#mute').html('Unmute (m)');
    } else {
      $('#mute').html('Mute (m)');
    }
  },

  /* Add a landed block to the underlying grid */
  updateGrid: function() {
    this.collection.each(function(model) {
      gridView.updateGrid(model)
      gridView.logGrid();
    }, this);
  },

  checkGameOver: function(block) {
    var blockY = block.get('y');
    if (blockY <= 0) {
      this.gameOver();
    }
  },

  gameOver: function() {
    gridView.drawGrid();
    this.playAudio('gameOver');
    clearInterval(app.interval);
    $('#message').html('GAME OVER!')
  },

  playAudio: function(key) {
    if (!this.muted) {
      var player = new Audio();
      player.src = jsfxr(app.sounds[key]);
      player.play();
    }
  },

  newGame: function() {
    this.start();
    this.playAudio('newGame');
    $('#message').html('');
    this.collection.reset();
    gridView.clearGrid();
    gridView.drawGrid();
    gridView.logGrid();
    this.spawnNewBlock();
  },

  /* Check to see if any rows are full */
  checkCompleteRows: function() {
    var completeRows = [];
    for (var y = 0; y < app.grid.length; y++) {
      var row = app.grid[y];
      var complete = true;
      for (var x = 0; x < row.length; x++) {
        if (row[x] != 1) {
          complete = false;
        }
      }
      if (complete) {
        completeRows.push(y);
      }
    }
    if (completeRows.length > 0) {
      this.clearCompleteRows(completeRows);
    } else {
      this.playAudio('land');
    }
  },

  /* Clear any complete rows from the grid and add a new clean row to the top */
  clearCompleteRows: function(completeRows) {
    this.playAudio('completeRow');
    for (var i = 0; i < completeRows.length; i++) {
      var rowIndex = completeRows[i];
      app.grid.splice(rowIndex, 1);
      var row = [];
      for (var x = 0; x < app.width; x++) {
        row.push(0);
      }
      app.grid.unshift(row);
    }
    gridView.logGrid();
  },

  /* Move a block left on keyboard input */
  moveLeft: function() {
    var self = this;
    this.collection.each(function(model) {
      var newX = model.get('x') - 1;
      if (model.get('x') > 0 && self.shapeFits(model.get('shape'), newX, model.get('y'))) {
        model.set('x', newX);
        self.playAudio('bluhp');
      }
    });
  },

  /* Move a block right on keyboard input */
  moveRight: function() {
    var self = this;
    this.collection.each(function(model) {
      var newX = model.get('x') + 1;
      if (model.get('x') + model.get('width') < app.width && self.shapeFits(model.get('shape'), newX, model.get('y'))) {
        model.set('x', newX);
        self.playAudio('bluhp');
      }
    });
  },

  /* Move a block down on keyboard input */
  moveDown: function() {
    var self = this;
    this.collection.each(function(model) {
      var newY = model.get('y') + 1;
      if (model.get('y') + model.get('height') < app.height && self.shapeFits(model.get('shape'), model.get('x'), newY)) {
        model.set('y', newY);
        self.playAudio('bluhp');
      }
    });
  },

  /* Automatically move a block down one step */
  descend: function() {
    var self = this;
    this.collection.each(function(model) {
      if (model.get('y') + model.get('height') < app.height && self.shapeFits(model.get('shape'), model.get('x'), model.get('y') + 1)) {
        model.set('y', model.get('y') + 1);
      } else {
        self.blockLanded(model);
      }
    });
  },

  /* Check if a given shape is within the bounds of the play area */
  shapeWithinBounds: function(shape, x, y) {
    if (x < 0) {
      return false;
    }
    if (x + shape[0].length > app.width) {
      return false;
    }
    return true;
  },

  /* Check if a shape fits in the play area and with the other blocks */
  rotatedShapeFits: function(shape, x, y) {
    return this.shapeFits(shape, x, y) && this.shapeWithinBounds(shape, x, y);
  },

  /* Rotate a block */
  rotate: function() {
    var self = this;
    this.collection.each(function(model) {
      // Method 1: (from http://stackoverflow.com/a/34164786)
      // Transpose the shape matrix (from http://stackoverflow.com/a/17428779):
      var transposed = _.zip.apply(_, model.get('shape'));
      var reversed = transposed.reverse();
      if (self.rotatedShapeFits(reversed, model.get('x'), model.get('y'))) {
        model.set('shape', reversed);
        var shape = model.get('shape');
        model.set('width', shape[0].length);
        model.set('height', shape.length);
        self.playAudio('bluhp');
      }
    });
  },

  /* Check if a block collides with landed blocks */
  shapeFits: function(shape, shapeX, shapeY) {
    for (var y = 0; y < shape.length; y++) {
      var row = shape[y];
      for (var x = 0; x < row.length; x++) {
        if (row[x] == 1) {
          var checkX = shapeX + x;
          var checkY = shapeY + y;
          if (app.grid[checkY][checkX] == 1) {
            return false;
          }
        }
      }
    }
    return true;
  }
});

app.ControlsView = Backbone.View.extend({

  el: $('#controls'),

  events: {
    'click #pause': 'pause',
    'click #mute': 'mute',
  },

  pause: function() {
    app.events.trigger('pause');
  },

  mute: function() {
    app.events.trigger('mute');
  }

});

app.GridView = Backbone.View.extend({

  /* Add a landed block to the underlying grid */
  updateGrid: function(model) {
    var shape = model.get('shape');
    var x = model.get('x');
    var y = model.get('y');
    for (var shape_y = 0; shape_y < shape.length; shape_y++) {
      var row = shape[shape_y];
      for (var shape_x = 0; shape_x < row.length; shape_x++) {
        if (row[shape_x] == 1) {
          app.grid[y + shape_y][x + shape_x] = 1;
        }
      }
    }
  },

  /* Draw any blocks (landed) from the underlying grid */
  drawGrid: function() {
    for (var y = 0; y < app.grid.length; y++) {
      var row = app.grid[y];
      for (var x = 0; x < row.length; x++) {
        if (row[x] == 1) {
          var x_pos = x * app.blockSize;
          var y_pos = y * app.blockSize;
          app.context.fillStyle = '#FFFFFF';
          app.context.fillRect(x_pos, y_pos, app.blockSize, app.blockSize);
        }
      }
    }
  },

  /* Reset the underlying grid to 0 */
  clearGrid: function() {
    app.grid = [];
    for (var y = 0; y < app.height; y++) {
      var row = [];
      for (var x = 0; x < app.width; x++) {
        row.push(0);
      }
      app.grid.push(row);
    }
  },

  /* Print out the underlying grid */
  logGrid: function() {
    if (app.logging) {
      var html = '';
      for (var y = 0; y < app.grid.length; y++) {
        var row = app.grid[y];
        var str = 'y: ' + String('00' + y).slice(-2) + ' | ';
        for (var x = 0; x < row.length; x++) {
          str += '<span class="grid';
          if (row[x] == 0) {
            str += 'Off';
          } else {
            str += 'On';
          }
          str += '">';
          str += row[x] + ' ';
          str += '</span>';
        }
        str += '<br>';
        html += str;
      }
      $('.grid').html(html);
    }
  }
});

app.boot = function() {

  app.canvas = document.querySelector("canvas");

  app.context = app.canvas.getContext("2d");

  app.context.shadowColor = 'white';

  app.context.shadowBlur = 15;

  app.blockSize = 20;

  app.logging = true;

  app.width = app.canvas.width / app.blockSize;

  app.height = app.canvas.height / app.blockSize;

  app.events = _.extend({}, Backbone.Events);

  // Sounds produced with http://www.superflashbros.net/as3sfxr/
  // and played in JS with https://github.com/mneubrand/jsfxr
  // Thanks to this post for info: https://codepen.io/jackrugile/post/arcade-audio-for-js13k-games
  app.sounds = {
    'bluhp': [2, 0.01, 0.13, 0.49, 0.33, 0.31, 0.06, 0.02, -0.9, 0.05, 0.51, 0.08, 0.08, 0.1062, 0.491, 0.12, -0.64, -0.5566, 0.68, -0.5, 0.24, 0.29, 0.12, 0.3],
    'newGame': [2, , 0.19, 0.67, 0.6034, 0.35, , -0.02, , 0.49, 0.62, 0.0382, , 0.6885, -0.0552, 0.03, -0.74, 0.0058, 0.23, 0.1399, 0.01, 0.09, 0.06, 0.25],
    'land': [2, , 0.103, , 0.3933, 0.4497, 0.0299, 0.0561, -0.0534, 0.4388, 0.0143, -0.3671, 0.2831, 0.8169, -0.133, 0.7848, -0.0841, -0.5874, 0.9552, -0.129, 0.4723, 0.1745, -0.0226, 0.2],
    'completeRow': [0, 0.0007, 0.3015, 0.5485, 0.39, 0.5029, , 0.7037, 0.3194, , , 0.665, 0.228, 0.3467, 0.05, 0.5698, -0.1913, -0.0563, 0.3286, -0.0055, , 0.4958, 0.0814, 0.5],
    'gameOver': [2, 0.0009, 0.41, 0.0873, 0.6029, 0.36, , -0.0999, -0.0799, , -0.4843, -0.0152, , -0.617, -0.0004, -0.6454, -0.12, 0.4399, 0.59, 0.1799, 0.21, 0.15, -0.24, 0.5]
  };

  app.pointsPerLine = {
    1: 40,
    2: 100,
    3: 300,
    4: 1200
  };

  app.shapes = [{
    shape: [
      [0, 1, 0],
      [1, 1, 1]
    ]
  }, {
    shape: [
      [0, 1, 1],
      [1, 1, 0]
    ]
  }, {
    shape: [
      [1, 1, 0],
      [0, 1, 1]
    ]
  }, {
    shape: [
      [1],
      [1],
      [1],
      [1]
    ]
  }, {
    shape: [
      [1, 1],
      [1, 1]
    ]
  }, {
    shape: [
      [1, 1],
      [1, 0],
      [1, 0]
    ]
  }, {
    shape: [
      [1, 1],
      [0, 1],
      [0, 1]
    ]
  }, ];

  var shapePos = _.random(app.shapes.length - 1);
  app.blocks = [app.shapes[shapePos]];

  app.boardView = new app.BoardView();

  // Controls:
  new app.ControlsView();

}

$(function() {
  app.boot()
});
Run Pen

External CSS

  1. https://fonts.googleapis.com/css?family=Lato

External JavaScript

  1. //cdnjs.cloudflare.com/ajax/libs/jquery/2.1.3/jquery.min.js
  2. //cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.2/underscore-min.js
  3. //cdnjs.cloudflare.com/ajax/libs/backbone.js/1.1.2/backbone-min.js
  4. https://cdn.rawgit.com/mneubrand/jsfxr/master/jsfxr.js