Some time ago I wrote a post called a very classy snake, inspired by a YouTube video and to try to touch on ES6, canvas and game programming basics.
Shortly afterwards, as it usually does, youtube started suggesting similar videos, and I found myself looking at this tetris on C++ video. C++ is not my thing lately, but I wanted an excuse to play some more with ES6 and canvas, so I though, why not combine the teachings from both videos to create a canvas tetris?
- Boilerplate
- Playing Field
- A single piece
- Movement and collision
- Touchdown and new piece
- Clearing lines and scoring
1. Boilerplate
In the begining, I just copied the html from the snake game, changing just the canvas dimensions to the proportions of the tetris pit (taken from the research the pal from the video did, and by research I mean he counted the squares on a GameBoy, so I did not have to :-)
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>repl.it</title>
<link href="style.css" rel="stylesheet" type="text/css" />
</head>
<body>
<canvas id='field' width='240' height='360'>Loading...</div>
<script src='script.js'></script>
<script>
window.onload = () => { let game = new Game({canvasId: 'field'}); };
</script>
</body>
</html>
Once we have this, we will copy over the skeleton of a game. What do I mean from skeleton. Most classic games have a very similar scaffolding, this is:
- Capture user input
- Calculate the new game state
- Redraw the game GUI based on the new state
This is usually called The game loop because it was, you guessed it, implemented into an infite loop, broken only by win and loss conditions.
As we are in javascript, we are making a slightly more asynchronous version of this, reading user inputs out of events, and executing the state recalculation and the screen redrawing with a setInterval
.
// jshint esnext:true
class Game {
constructor({ canvasId }){
// this.field = new Field({...});
this.init();
}
init(){
addEventListener('keydown', (e) => { this.userInput(e) } ); // User input
setInterval( () => { this.game() }, 1000 / 8); // Game loop
}
userInput(e){
// nothing here yet
}
game(){
// nothing here yet
}
}
Once you have this, you only have to fill in the gaps, and it is as easy as drawing an owl:
2. Playing field
Now let's go for something you will be able to see at last. To that end, there are two bits we will rescue from the snake game:
- First, the canvas initialisation code:
let canvas = document.getElementById(canvasId);
this.context = canvas.getContext('2d');
- Then the code to draw a single square on our imaginary grid:
// Draw a single tile (using canvas primitives)
drawTile(x, y, color){
this.context.fillStyle = color;
this.context.fillRect(
x * this.size, // x tiles to the rigth
y * this.size, // y tiles down
this.size - 1, // almost as wide as a tile
this.size - 1); // almost as tall
}
We are using the fillRect primitive, it can only draw rectangles, but our Tetris game will have a fat pixels aesthetic, so that will be enough for us.
We will create a new class, in charge of holding the game state and drawing the background screen.
class Field{
constructor({width, height, size, canvasId}){
this.width = width; // number of tiles sideways
this.height = height; // number of tiles downward
this.size = size; // size of a tile in pixels
this.init(canvasId); // initialize the field
}
init(canvasId){
// first, set up the canvas context:
let canvas = document.getElementById(canvasId);
this.context = canvas.getContext('2d');
// then set up the grid
this.initTileGrid();
}
// Create the original grid of tiles composed of void and walls
initTileGrid(){
this.tiles = []; // a list of columns
for(let x = 0; x < this.width; x += 1) {
this.tiles[x] = []; // a single column
for(let y = 0; y < this.height; y +=1 ) {
this.tiles[x][y] = this.isWall(x, y) ? 'w' : ' ';
}
}
}
// Are these x,y coordinates part of a wall?
// use for drawing and for wall-collision detection
isWall(x, y){
return (x === 0 || // left wall
x === (this.width - 1) || // right wall
y === (this.height-1)); // floor
}
// For every tile in the grid, drwa a square of the apropriate color
draw(){
for(let x = 0; x < this.width; x += 1) {
for(let y = 0; y < this.height; y +=1 ) {
this.drawTile(x, y, this.colorFor(this.tiles[x][y]));
}
}
}
// Draw a single tile (using canvas primitives)
drawTile(x, y, color){
this.context.fillStyle = color;
this.context.fillRect(
x * this.size, // x tiles to the right
y * this.size, // y tiles down
this.size - 1, // almost as wide as a tile
this.size - 1); // almost as tall
}
// Relate grid cell content constants with tile colors
colorFor(content){
let color = { w: 'grey' }[content];
return color || 'black';
}
}
This is ready to roll, but the Game class is not yet referring to it, so we need to do this small changes:
class Game {
constructor({ canvasId }){
this.field = new Field({
width: 12, // number of tiles to the right
height: 18, // number of tiles downwards
size: 20, // side of the tile in pixels
canvasId: canvasId // id of the cavnas tag
});
this.init();
}
// ... the rest remains unchanged
}
Once you have, you should be able to see something like this:
Things to observe:
We are using cartesian (that is 2D) coordinates, but we are starting the count at the top-left corner of the canvas, because that is what canvas functions do, and it saves us from some conversion math.
A single piece
A tetris piece or, as I learnt in the video, a tetronimo can be represented as a 4x4 binary matrix of full and empty spaces.
// If you squint you see the 'L' piece:
[[' ','L',' ',' '],
[' ','L',' ',' '],
[' ','L','L',' '],
[' ',' ',' ',' ']]
But if we concatenate those 4 lists it can be simplified as a list:
[' ','L',' ',' ',' ','L',' ',' ',' ','L','L',' ',' ',' ',' ',' ']
where you use (x,y) => { list[4*y + x] }
to see each position as a cell.
And javascript being weakly typed allows you to do this with a string as well:
' L L LL '
The video uses A,B,C... letters to refer to (and draw) the pieces, I prefer to use the letters that remind me most of the tetromino's shape, thus the 'L' here.
Pieces have three main motions, sideways, downward and rotation. Sideways and downward motions can be easily figured adding units to the coordinates, so we will deal first with the more complex one, rotation.
Rotation:
Let's draw the numbered positions from our strings in the position they will have in the 4x4 grid, and then, figure out (or copy from the video ;-) the math to have a matrix rotation:
var grid = [
0, 1, 2, 3,
4, 5, 6, 7,
8, 9, 10, 11,
12, 13, 14, 15
];
var newGrid = [];
for (let i0 = 0; i0 < 16; i0++){
// convert to x/y
let x0 = i0 % 4;
let y0 = Math.floor(i0 / 4);
// find new x/y
let x1 = 4 - y0 - 1;
let y1 = x0;
//convert back to index
var i1 = y1 * 4 + x1;
newGrid[i1] = grid[i0];
}
console.log(newGrid);
// [12, 8, 4, 0,
// 13, 9, 5, 1,
// 14, 10, 6, 2,
// 15, 11, 7, 3]
If you do this with a piece represented as a string you get:
var grid = ' I I I I ';
// Visual help: this is the above as a 4x4 grid:
// [" ", " ", "I", " ",
// " ", " ", "I", " ",
// " ", " ", "I", " ",
// " ", " ", "I", " "]
var newGrid = [];
for (let i0 = 0; i0 < 16; i0++){
// convert to x/y
let x0 = i0 % 4;
let y0 = Math.floor(i0 / 4);
// find new x/y
let x1 = 4 - y0 - 1;
let y1 = x0;
//convert back to index
var i1 = y1 * 4 + x1;
newGrid[i1] = grid[i0];
}
console.log(newGrid);
// [" ", " ", " ", " ",
// " ", " ", " ", " ",
// "I", "I", "I", "I",
// " ", " ", " ", " "]
console.log(newGrid.join(''));
// " IIII "
Let's build a new Piece
class with this logic in it:
class Piece{
constructor({variant, x, y}){
this.x = x;
this.y = y;
this.contents = this.variants()[variant];
}
variants(){
return { // 16 chars = 4x4 char grid
i: ' i i i i ', // 4x1 column
t: ' t tt t ', // short 'T' shape
l: ' l l ll ', // L (short arm right)
j: ' j j jj ', // J (sort arm left)
o: ' oo oo ', // square, centered or rotation would displace
s: ' ss ss ', // step climbing right
z: ' zz zz ' // step climbing left
};
}
rotate(){
let newGrid = [];
for (let i0 = 0; i0 < 16; i0++){
// convert to x/y
let x0 = i0 % 4;
let y0 = Math.floor(i0 / 4);
// find new x/y
let x1 = 4 - y0 - 1;
let y1 = x0;
//convert back to index
var i1 = y1 * 4 + x1;
newGrid[i1] = this.contents[i0];
}
this.contents = newGrid.join('');
}
reverse(){ // 1/4 left = 3/4 right
rotate();
rotate();
rotate();
}
toString(){
return [this.contents.slice(0 , 4),
this.contents.slice(4 , 8),
this.contents.slice(8 , 12),
this.contents.slice(12, 16)].join("\n");
}
}
let p = new Piece({variant: 'l', x: 5, y: 0})
console.log(`----\n${p.toString()}\n----`);
p.rotate();
console.log(`----\n${p.toString()}\n----`);
p.rotate();
console.log(`----\n${p.toString()}\n----`);
p.rotate();
console.log(`----\n${p.toString()}\n----`);
If you run this code, you get this output:
"----
L
L
LL
----"
"----
LLL
L
----"
"----
LL
L
L
----"
"----
L
LLL
----"
Can you see the 'L' piece rotating clockwise?
The .toString()
method is not needed for the game logic but it is useful for debugging, feel free to leave it there if it helps you.
Next step: draw it onto the canvas. The drawing logic is on the Field
so we are going to add a method to draw the current piece.
Changes to Field
Initialize the current Piece:
init(canvasId){
// (...) the rest of the method unchanged (...)
this.currentPiece = new Piece({x: 4,y: 0});
}
The draw
method:
// For every tile in the grid, draw a square of the apropriate color
draw(){
// (...) the rest of the method unchanged (...)
this.drawPiece(this.currentPiece);
}
And a new drawPiece
function:
drawPiece(piece){
let tile = ' ';
for(let x = 0; x < 4; x += 1){
for(let y = 0; y < 4; y += 1){
tile = piece.at(x,y)
if (tile !== ' '){
this.drawTile(piece.x + x,
piece.y + y,
this.colorFor(tile));
} // non empty
} // column tiles
} // piece columns
}
As you see, we are still using the colorFor
method to choose the color of the tiles, so now we need a coor for every piece, so we go to the Tetris page on wikipedia to choose them:
// Relate grid cell content constants with tile colors
colorFor(content){
let color = {
w: 'grey',
i: 'lightblue',
t: 'lightgreen',
l: 'orange',
j: 'blue',
o: 'yellow',
s: 'lime',
z: 'red'
}[content];
return color || 'black';
}
The final version of the Piece
class has the ability to randomly choose a variant upon initialisation:
class Piece{
constructor({x, y}){
this.x = x;
this.y = y;
this.contents = this.chooseVariant();
}
// changed from variants to this, with the random logic
chooseVariant(){
// https://stackoverflow.com/questions/2532218/pick-random-property-from-a-javascript-object
let variants = {
i: ' i i i i ', // 16 chars = 4x4 char grid
t: ' t tt t ',
l: ' l l ll ',
j: ' j j jj ',
o: ' oo oo ', // centered or rotation would displace
s: ' ss ss ',
z: ' zz zz '
};
let keys = Object.keys(variants);
return variants[keys[ keys.length * Math.random() << 0]]; // << 0 is shorcut for Math.round
}
at(x, y){
return this.contents[(y * 4) + (x % 4)];
}
rotate(){
let newGrid = [];
for (let i0 = 0; i0 < 16; i0++){
// convert to x/y
let x0 = i0 % 4;
let y0 = Math.floor(i0 / 4);
// find new x/y
let x1 = 4 - y0 - 1;
let y1 = x0;
// convert back to index
var i1 = y1 * 4 + x1;
newGrid[i1] = this.contents[i0];
}
this.contents = newGrid.join('');
}
reverse(){ // 1/4 left = 3/4 right
rotate();
rotate();
rotate();
}
}
Once you have this code in place, you should be able to see something like this:
Bear in mind it probably chose a different tetromino for you, and will choose a random one every time you run the code.
Movement and collision
Now that we have a Playing field, and a piece on it, it is time to get interactive, so we are going to listen to player input and react to it.
Also we have walls, and they would not be worth such name it stuff just went through, right?.
So this is the strategy for this section:
- Read user input
- Create a displaced or rotated version of the piece
- Check if the virtual piece fits (does not collide)
- If it fits, it becomes the current piece
- If it does not, movement gets blocked (for now, we will see what else later)
Read user input
I am going to be totally lazy here and copy over from the snake game:
// on Game class
userInput(event){
const arrows = { left: 37, up: 38, right: 39, down: 40};
const actions = {
[arrows.left]: 'moveLeft',
[arrows.up]: 'rotate',
[arrows.right]: 'moveRight',
[arrows.down]: 'moveDown'
}
if (actions[event.keyCode] !== undefined){ // ignore unmapped keys
this.field.handle(actions[event.keyCode]);
}
}
Create the virtual piece (we make it accept contents
for this)
There is no deep cloning out of the box on ES6 so we just initialise a new Piece with the same properties and then apply the motion indicated by the user's input:
Piece
class:
class Piece{
constructor(options = {}) {
const defaults = { x: 0 , y: 0, contents: null };
Object.assign(this, defaults, options);
// accept contents for piece copying, select random for new pieces:
this.contents = this.contents || this.chooseVariant();
}
chooseVariant(){
// unmodified
}
//// User actions:
moveRight(){
this.x += 1;
}
moveLeft(){
this.x -= 1;
}
moveDown(){
this.y += 1;
}
rotate(){
// unmodified
}
// return a copy of the object:
dup(){
return new Piece({x: this.x, y: this.y, contents: this.contents});
}
And now the handle
method in the Field
class:
handle(action){
// make a copy of the existing piece:
let newPiece = this.currentPiece.dup();
// effect the user selected change on the new piece:
newPiece[action]();
// temporal, to see the effect:
this.currentPiece = newPiece;
this.draw();
}
After this, you should be able to move your piece sideways and downwards, but alas, it does not stop on walls.
Detect collision
This handle
function is not very smart, so we are going to add a check to see if a piece can fit in the place we are trying to send it to, before effectively doing the move:
handle(action){
// make a copy of the existing piece:
let newPiece = this.currentPiece.dup();
newPiece[action](); // move or rotate according to userInput
if (this.canFit(newPiece)){
this.currentPiece = newPiece;
} else {
console.log('colision!');
// touchDown?
}
this.draw();
}
This is very similar to what we have before, but now, how do we know if the piece can indeed fit. We don't need 4x4 tiles free because tetronimos do not occuppy their full grid, to achieve the puzzle effect we only want to check if every tile on the piece grid is either empty on the piece or on the field, in either case there is no collision. Collosions happen when a non-empty cell from the piece is atop a non-empty cell of the field.
Let's translate all this jargon to code:
canFit(piece){ // for every overlap tile between the piece and the field:
for(let x = 0; x < 4; x++){
for(let y = 0; y < 4; y++){
if (piece.at(x, y) !== ' ' && // piece is not empty
this.tiles[piece.x + x][piece.y + y] != ' ' ){ // field is not empty
return false; //there is collision
}
}
}
return true; // if there are no collisions, it can fit
}
After this, you can still move your pieces, but no longer overlap them with the walls or floor. The console.log('collision!')
will be executed every time you go over a wall or the floor, but the piece won't move.
Before going on, I noticed that the rotations had a strange symmetry. This is, the pieces rotating around different axis from what they do on the original game. First I fixed this on the square, going:
From this: To this:
'oo ' ' '
'oo ' ' oo '
' ' ' oo '
' ' ' '
But that trick did not work for every piece. So I dug deeper, and I noticed I felt uncomfortable about the literal 4's sprinkled all over the code, so I thought: what if different pieces are different sizes?
So I made these changes to Piece
:
- Added a
length
and aside
getters toPiece
, to use instead of 16 and 4 throughout the code. - Edited every method using the Piece's length or side to use the new attributes.
- Once everything was working again, I changed the pieces strings to the smallest possible grids with the better symmetry I could get.
Here are the changed methods in piece:
class Piece{
constructor(options = {}) {
const defaults = { x: 0 , y: 0, contents: null };
Object.assign(this, defaults, options);
this.contents = this.contents || this.chooseVariant();
}
chooseVariant(){
// https://stackoverflow.com/questions/2532218/pick-random-property-from-a-javascript-object
let variants = {
i: ' i '+
' i '+
' i '+
' i ', // 16 chars = 4x4 char grid
t: ' t '+ // 3x3
'ttt'+
' ',
l: 'l '+
'l '+
'll ',
j: ' j'+
' j'+
' jj',
o: 'oo'+ // 2x2
'oo',
s: ' ss'+
'ss '+
' ',
z: 'zz '+
' zz'+
' '
};
let keys = Object.keys(variants);
this.variant = this.variant || (keys[ keys.length * Math.random() << 0]);
return variants[this.variant];
}
get length(){
return this.contents.length;
}
get side(){
return Math.sqrt(this.length);
}
at(x, y){
return this.contents[(y * this.side + (x % this.side )) ];
}
// ... moveRight/Left/Down unmodified
rotate(){
let newGrid = [];
for (let i0 = 0; i0 < this.length; i0++){
// convert to x/y
let x0 = i0 % this.side;
let y0 = Math.floor(i0 / this.side);
// find new x/y
let x1 = this.side - y0 - 1;
let y1 = x0;
// convert back to index
var i1 = y1 * this.side + x1;
newGrid[i1] = this.contents[i0];
}
this.contents = newGrid.join('');
}
And here you have the changed methods outside of Piece
, which are the two Field
methods that received a Piece
as argument, canFit
and drawPiece
:
// Field class
canFit(piece){ // for every overlap tile between the piece and the field:
for(let x = 0; x < piece.side; x++){
for(let y = 0; y < piece.side; y++){
if (piece.at(x, y) !== ' ' && // piece is not empty
this.tiles[piece.x + x][piece.y + y] != ' ' ){ // field is not empty
return false; //there is collision
}
}
}
return true; // if there are no collisions, it can fit
}
//...
drawPiece(piece){
let tile = ' ';
for(let x = 0; x < piece.side; x += 1){
for(let y = 0; y < piece.side; y += 1){
tile = piece.at(x,y);
if (tile !== ' '){
this.drawTile(piece.x + x,
piece.y + y,
this.colorFor(tile));
} // non empty
} // column tiles
} // piece columns
}
Once you have this, you have the original rotation on all pieces but the 4x1 column.
Time to start piling pieces and clearing lines now.
If you read all this, first of all, thank you very much! I hope you are having so much fun reading and, I hope, following along, as I had figuring out how to explain it.
Second, you might be curious how does this continue, but if you want to know that, you will have to jump to Rocknrollesque's post #TODO: review the link
.
I created my dev.to account inspired by her, and I wanted to return the favour, so I challenged her to finish this post, so that she had to create a dev.to blog of her own.
So go now to find about:
Touchdown and new piece
and
Top comments (0)