Overview
Part 1 - The Basic Set Up
Part 2 - The Board and Pieces
Part 3 - Player Movement and Collision Detection
Part 4 - SRS Guidelines: Spawn Orientation, Basic Rotation, and Wall Kicks
Part 5 - Automatic Falling and Clearing Lines | preview)
Part 6 - Next Piece (github | preview) | (github | preview), Levels, Lines, Score (github | preview), and Statistics (github | preview)
Now that our game is chugging along it's time to update all our display modules with data-driven displays.
Next Piece Preview
The first thing we're going to tackle is displaying the piece that will spawn next.
Create the Next Piece store
First, we'll add a new store to keep track of the next piece and update it every time we merge a piece to the board. It's a relatively straight-forward writable store. It's only job is to update what the next piece in the queue is.
src/stores/nextPiece.js
import { writable } from 'svelte/store'
const initialState = {
name: '',
matrix: [[0]],
id: -1,
color: 0,
}
function createNextPiece(piece = {}) {
const { subscribe, set } = writable(piece)
return {
subscribe,
setNextPiece: nextPiece => set(nextPiece),
}
}
export default createNextPiece(initialState)
Create the Piece component
To make displaying this piece a little more contained we'll create a new component - Piece.svelte
. This will be a display component that draws a piece to its canvas element when the nextPiece
store is updated. The NextPiece.svelte
display container will make use of it (as well as another updated display later on).
src/components/Piece.svelte
<script>
import { onMount } from 'svelte'
import canvasHelper from '../canvasHelpers'
export let piece
export let width
export let height
export let xOffset = 0
export let yOffset = 0
let ref
let ctx
$: piece, ctx, drawPiece(piece.matrix)
function drawPiece(matrix) {
if (ctx) {
canvasHelper.clearCanvas(ctx, '#000000')
canvasHelper.drawMatrix(ctx, matrix, x, 1)
}
}
onMount(() => {
ctx = ref.getContext('2d')
})
</script>
<div>
<canvas bind:this={ref} {width} {height} />
</div>
We're using a new style of declaring a reactive statement with dependencies. We're putting our dependencies piece
and ctx
before our call to drawPiece()
so that it will run whenever piece
or ctx
change.
The other method we have used is using the dependencies as an argument of the function
Update the NextPiece display
Now, onto our NextPiece.svelte
container. We will replace our dummy content with our Piece
component. In addition we pull our next piece out of the svelte context (which we will update in Tetris.svelte
) and add some styles.
src/containers/NextPiece.svelte
<script>
import { getContext } from 'svelte'
import { TETRIS } from '../constants'
import Display from '../components/Display.svelte'
import Piece from '../components/Piece.svelte'
const { nextPiece } = getContext(TETRIS)
export let width
export let height
let canvas
</script>
<Display>
<div>
<span>Next</span>
<Piece {width} {height} piece={$nextPiece} />
</div>
</Display>
<style>
span {
display: block;
text-align: center;
}
</style>
Finally it's time to incorporate all that into our main Tetris
file. We import nextPiece
with the rest of our stores and then add it in our context definition. We define two new local constants to hold the width and height of our NextPiece
display container. We define two new functinos for creating our current and upcoming pieces and then update the code in centerPiece
, resetGame
, and animate
that dealt with defining or creating our current piece.
src/containers/Tetris.svelte
<script>
...
// stores
import board from '../stores/board.js'
import currentPiece from '../stores/currentPiece.js'
import lines from '../stores/lines.js'
import { fallRate } from '../stores/fallRate.js'
import nextPiece from '../stores/nextPiece.js'
$: console.log('lines: ', $lines)
// initialize context
setContext(TETRIS, { currentPiece, board, nextPiece })
const canvasWidth = COLS * BLOCK_SIZE
const canvasHeight = ROWS * BLOCK_SIZE
const nextWidth = 4 * BLOCK_SIZE
const nextHeight = 4 * BLOCK_SIZE
...
function randomizeNextPiece() {
nextPiece.setNextPiece(getRandomPiece())
}
function makeNextPieceCurrent() {
const spawnedPiece = centerPiece($nextPiece)
currentPiece.setCurrentPiece(spawnedPiece)
}
...
/**
* Positions a piece in the center of the board.
* @returns a copy of the input piece
*/
function centerPiece(piece) {
const klonedPiece = klona(piece)
klonedPiece.x = Math.floor((COLS - klonedPiece.matrix[0].length) / 2)
klonedPiece.y = klonedPiece.name === 'I' ? -1 : 0
return klonedPiece
}
function resetGame() {
// reset timers
timeSincePieceLastFell = 0
lastFrameTime = 0
// reset game objects
board.resetBoard()
// initialize pieces
randomizeNextPiece()
makeNextPieceCurrent()
randomizeNextPiece()
}
...
function animate(currentTime) {
...
// check collision on each paint
if (detectMatrixCollision($currentPiece, $board)) {
mergeCurrentPieceIntoBoard()
clearCompletedLines()
makeNextPieceCurrent()
randomizeNextPiece()
// If there is still a collision right after a new piece is spawned, the game ends.
if (detectMatrixCollision($currentPiece, $board)) {
console.error('Game over!')
return
}
}
animationID = requestAnimationFrame(animate)
}
...
</script>
<div class="game">
...
<section class="meta">
<!-- SCORE -->
<Score />
<!-- NEXT PIECE -->
<NextPiece width={nextWidth} height={nextHeight} />
<!-- LEVEL -->
<Level />
</section>
</div>
<style>
...
</style>
Now you can see the next piece in the queue.
Better Randomizer
We're currently using an unbiased randomizer to get our next piece. We're just grabbing a random piece from our tetromino array with no checks or balances as to whether a piece has or hasn't been pulled yet. You may have noticed that our current method can be a bit frustrating. With this type of unbiased randomization there is nothing stopping it from flooding you with the same piece 10 times in a row or on the other end creating a piece drought where you rarely if ever get a certain piece.
We're going to update our method to use the new official Random Generator. This generates a sequence of all seven one-sided tetrominoes (I, J, L, O, S, T, Z) randomly, as if they were drawn from a bag. When all seven tetrominoes in the bag are used we generate another bag.
There are other randomizers that could be used - all with their advantages and disadvantages but the 7 bag is the all around averagest of all the different types of randomizers we could use - it creates a not too difficult puzzle with an average prevention of floods and droughts... it's just right.
The shuffle utility
We will begin by defining our new shuffle utility in our utils.js
file. This will shuffle an array in place using the modern algorithm of the Fisher Yates shuffle. We begin with our collection of pieces which we want to randomly shuffle. Then, we randomly select one of the "unshuffled" items and swap the selected item with the last item in the collection that has not been selected. This continues until there are no remaining "unshuffled" items.
src/utils.js
/**
* Shuffles an array in place
* - uses the modern version of the Fisher-Yates shuffle
*
* @param {Array} a The array to shuffle
* @returns {Array} The shuffled array
*/
function shuffle(a) {
let counter = a.length
// While there are elements in the array
while (counter > 0) {
// Pick a random index
let index = Math.floor(Math.random() * counter)
// Decrease counter by 1
counter--
// And swap the last element with it
let temp = a[counter]
a[counter] = a[index]
a[index] = temp
}
return a
}
export { inRange, times, constant, partial, lessThan, shuffle }
Implement the bag
Now let's update our game to use the bag system and our new shuffle function. In Tetris.svelte
import our shuffle utility alongside our other helpers and initialize a new local variable bag
as an empty array. This is what we'll be populating and pulling from to recreate the idea of a bag of pieces. We'll add a function createBag
to klone our tetrominos into the bag and shuffle them and then update randomizeNextPiece
to check our bag and if it's empty generate a new one otherwise, pull a piece out.
src/Tetris.svelte
<script>
...
// helpers
import { detectMatrixCollision, getFilledRows } from '../matrixHelpers'
import { shuffle } from '../utils'
// local variables
...
let bag = []
function createBag() {
// make a bag
bag = klona(tetrominos)
// shuffle pieces
shuffle(bag)
}
function randomizeNextPiece() {
// if there are no pieces in the bag
if (bag.length === 0) {
// create a new bag
createBag()
}
// grab next piece
const piece = bag.pop()
nextPiece.setNextPiece(piece)
}
...
</script>
Note that we also removed the getRandomPiece()
function since that is now part of our bag creation.
Levels, Lines, and Scoring
The levels, lines, and score displays should be fairly simple to update since during the course of the tutorial we implemented almost everything we need to get them displayed. besides adding our score calculation all that needs to be done is make some adjustments to our components.
Displaying The Current Level
In order to use our level
store in the Level.svelte
display we first have to import and add the derived level
store into our context in Tetris.svelte
. While we are in there - delete that console.log
of the lines.
src/containers/Tetris.svelte
<script>
...
// stores
...
import { level } from '../stores/level.js'
// initialize context
setContext(TETRIS, { currentPiece, board, nextPiece, level })
...
</script>
Then in our Level.svelte
display we get our level
store out of context and add a couple local constants to store some amounts for padding our number with zeroes and spaces. The padLevel
function uses native string padding to create a level display with leading and ending spaces along with leading zeroes to match our intended design. We also create our local display variable using a reactive statement so it recalculates when the level updates.
There is also a small style update to handle our display string's white space since by default sequences of white space will be collapsed by HTML. So we declare have our display use white-space: pre;
which will preserve our intended white space.
src/containers/Level.svelte
<script>
import { getContext } from 'svelte'
import Display from '../components/Display.svelte'
import { TETRIS } from '../constants'
const zeroPaddingTotal = 2
const displayLength = 5
const { level } = getContext(TETRIS)
$: display = padLevel($level)
function padLevel(currentLevel) {
// convert level number to string
const level = currentLevel.toString()
// determine amount to pad for the extra space at end
const spacePadStart = Math.floor((5 - level.length) / 2) + level.length
return level
.padStart(zeroPaddingTotal, '0')
.padEnd(spacePadStart, ' ')
.padStart(displayLength, ' ')
}
</script>
<Display>
<div>
<span>Level</span>
<span class="display">{display}</span>
</div>
</Display>
<style>
span {
display: block;
}
.display {
white-space: pre;
}
</style>
If you save and check it out we have a properly displayed Level indicator.
Displaying Lines
Displaying our lines isn't too different from what we just did with our levels. The store is already imported in Tetris.svelte
so we just need to add it to context.
src/containers/Tetris.svelte
// initialize context
setContext(TETRIS, {
currentPiece,
board,
nextPiece,
level,
lines
})
Then, it's just a matter of updating the display container. Much like Levels.svelte
we get our store from context, set some spacing variables, and create a reactive display variable to call a padding function when our line store updates.
src/containers/Lines.svelte
<script>
import { getContext } from 'svelte'
import Display from '../components/Display.svelte'
import { TETRIS } from '../constants'
const { lines } = getContext(TETRIS)
const zeroPaddingTotal = 3
$: display = padLines($lines)
function padLines(currentLines) {
// convert level number to string
const lines = currentLines.toString()
// determine amount to pad for the extra space at end
const spacePadStart = Math.floor((5 - lines.length) / 2) + lines.length
return lines.padStart(zeroPaddingTotal, '0')
}
</script>
<Display>
<div>
<span>Lines {display}</span>
</div>
</Display>
<style>
div {
width: 100%;
}
span {
display: block;
text-align: right;
}
</style>
Clear some lines and watch that line count grow!
Calculating Score
Tetris awards points for completing lines. We are going to implement the scoring system originallly used by the NES version of Tetris. It has a simple formula we can apply to all levels.
Points awarded for 1 line: 40 _ (level + 1)
Points awarded for 2 line: 100 _ (level + 1)
Points awarded for 3 line: 300 _ (level + 1)
Points awarded for 4 line: 1200 _ (level + 1)
The level multiplier is based on the level after the line clear, not before so we will calculate score only after we have updated our lines
store which in turn updates our derivedlevel
store.
For each piece, the game will also award the number of points equal to the number of grid spaces that the player has continuously soft dropped the piece by holding the DOWN key. The amount of points are based only on the last press that leads to a lock; any earlier soft drops are not counted. Unlike the points for lines, this does not increase per level.
We'll start with a new constant to store the points awarded per line.
src/constants.js
// GENERAL
...
// Levels
...
// Points
export const LINE_POINTS = [40, 100, 300, 1200];
Let's create our score.js
store now.
src/stores/score.js
import { writable } from 'svelte/store'
import { LINE_POINTS } from '../constants'
const initialState = 0
function createScore(initialValue) {
const { subscribe, set, update } = writable(initialValue)
return {
subscribe,
resetScore: () => set(0),
addPieceScore: piecePoints => update(prevScore => prevScore + piecePoints),
addClearedLineScore(linesCleared, currentLevel) {
update(prevScore => {
const linesPointIndex = linesCleared - 1
const basePoints = LINE_POINTS[linesPointIndex]
const increase = basePoints * (currentLevel + 1)
return prevScore + increase
})
},
}
}
export default createScore(initialState)
We have two store methods - addPieceScore
and addClearedLineScore
- to handle our scoring. The addPieceScore
method simply adds an amount passed in to our currentScore. The amount will be equal to the number of spaces the piece was dropped before locking. The addClearedLineScore
takes the number of lines cleard along with the current level and calculates the points based on the NES formula.
Displaying Score
Now that the data is in place let's update our main Tetris.svelte
file to use our store and then we will update the Score.svelte
file to display our score.
The Tetris.svelte
file imports the store and adds it to our context. We add a new local variable to track our drop count - softDropCount
. We then update our mergeCurrentPieceIntoBoard
and clearCompleteLines
to integrate and calculate scoring. We also have to update handlePlayerMovement
to calculate our extra points when we hold the DOWN key.
src/containers/Tetris.svelte
<script>
...
// stores
...
import score from '../stores/score.js'
// initialize context
setContext(TETRIS, {
currentPiece,
board,
nextPiece,
level,
lines,
score,
stats,
})
...
let bag = []
let softDropCount = 0
...
function mergeCurrentPieceIntoBoard() {
...
// add points equal to spaces DOWN was held
score.addPieceScore(softDropCount)
// reset the drop count
softDropCount = 0
}
...
function clearCompletedLines() {
...
if (numberOfClearedLines > 0) {
lines.setLines($lines + numberOfClearedLines)
board.clearCompletedLines()
// update score after any line and level updates
score.updateScore(numberOfClearedLines, $level)
}
}
...
function handlePlayerMovement(currentTime) {
...
if (pressed.some(...DOWN_KEYS)) {
if (isDownMovementAllowed) {
lastDownMove = currentTime
timeSincePieceLastFell = 0
// increase count for each space moved
softDropCount += 1
currentPiece.movePieceDown()
}
} else {
lastDownMove = 0
// reset drop count
softDropCount = 0
}
}
...
</script>
Statistics
On to the final display - the Statistics (another element borrowed from the classic NES game). This shows a running tally of the pieces that have spawned. Our version of the game has updated the truly random piece generator with the 7 bag randomizer so the numbers in the statistics window will all br relatively equal, but it's still a neat little feature to add.
Calculating Stats
We will create a store to manage our statistics. This way updates will trickle own and cause re-rendering of our display component. It will be responsible for creating the base statistics which is just an array wiht an object that has an id and count that corresponds to each tetromino piece. It also will manage updates to the array with the updateStats
method. This simply increases the stat count for the id passed in.
src/stores/stats.js
import { writable } from 'svelte/store'
const initialState = []
function createStats() {
const { subscribe, set, update } = writable(initialState)
return {
subscribe,
setBaseStats(pieces) {
let stats = []
stats = pieces.map(piece => {
return {
id: piece.id,
count: 0,
}
})
set(stats)
},
updateStats(id) {
update(prevStats => {
const index = prevStats.findIndex(item => item.id === id)
prevStats[index].count++
return prevStats
})
},
}
}
export default createStats()
Displaying Stats
In order to display our statistics we will need to update our main Tetris.svelte
file. This shoud be very familiar after this article - importing the store and adding to context. In addition, we will call updateStats()
whenever a new current piece spawns.
src/containers/Tetris.svelte
<script>
...
// stores
...
import stats from '../stores/stats.js'
stats.setBaseStats(tetrominos)
// initialize context
setContext(TETRIS, {
currentPiece,
board,
nextPiece,
level,
lines,
score,
tetrominos,
stats,
})
...
function makeNextPieceCurrent() {
const spawnedPiece = centerPiece($nextPiece)
currentPiece.setCurrentPiece(spawnedPiece)
stats.updateStats($currentPiece.id)
}
...
</script>
Before we move to displaying this we need to make a quick pitstop in our canvasHelpers
file to make a correction to how we draw our highlights. The lineTo
calls were subtracting "18" to get to the number "2". Instead we will use the magic number "2" throughout. It's a minor adjustment but will help with keeping our overall rendering consistent.
src/canvasHelpers.js
function drawHighlight(context, x, y, w, h) {
context.beginPath()
context.moveTo(x, y)
context.lineTo(x + w, y)
context.lineTo(x + w - 2, y + 2)
context.lineTo(x + 2, y + 2)
context.closePath()
context.fillStyle = `rgba(255, 255, 255, .2)`
context.fill()
context.beginPath()
context.moveTo(x + w, y)
context.lineTo(x + w, y + h)
context.lineTo(x + w - 2, y + h - 2)
context.lineTo(x + w - 2, y + 2)
context.closePath()
context.fillStyle = `rgba(255,255,255, .2)`
context.fill()
context.beginPath()
context.moveTo(x + w, y + h)
context.lineTo(x, y + h)
context.lineTo(x + 2, y + h - 2)
context.lineTo(x + w - 2, y + h - 2)
context.closePath()
context.fillStyle = `rgba(0,0,0, .4)`
context.fill()
context.beginPath()
context.moveTo(x, y + h)
context.lineTo(x, y)
context.lineTo(x + 2, y + 2)
context.lineTo(x + 2, y + h - 2)
context.closePath()
context.fillStyle = `rgba(0,0,0, .2)`
context.fill()
}
Now that we have some statistic data, update our Statistics.svelte
display component to grab the store from context and then loop through it to create our little display.
src/containers/Statistics.svelte
<script>
import { onMount, getContext } from 'svelte'
import { get } from 'svelte/store'
import Display from '../components/Display.svelte'
import Piece from '../components/Piece.svelte'
import { TETRIS } from '../constants'
const { stats, tetrominos } = getContext(TETRIS)
</script>
<Display>
<div>
<div class="title">Statistics</div>
{#each $stats as stat}
<div class="stat">
<Piece
width={45}
height={20}
piece={tetrominos[stat.id - 1]}
/>
<span>{stat.count.toString().padStart(3, '0')}</span>
</div>
{/each}
</div>
</Display>
<style>
.stat {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 5px;
}
</style>
If you check out the game you'll notice a big fat error message.
This one stumped me but I think this is due to a component mounting/store populating race condition. Instead of checking for a matrix
before drawing a piece, changing it to check for the ctx
of our canvas seems to do the trick:
src/containers/Piece.svelte
function drawCanvas(matrix) {
if (ctx) {
const x = (4 - matrix[0].length) / 2
canvasHelper.clearCanvas(ctx, '#000000')
canvasHelper.drawMatrix(ctx, matrix, x, 1)
}
}
Now - the error is gone but even though our stats are displaying none of the associated pieces have been drawn.
Well, that's not 100% accurate. They have been drawn but we just can't see them because we originally hard-coded our offsets into the drawCanvas
function inside of Piece.svelte
. We're going to refactor Piece.svelte
once again to make those offsets props. That way NextPiece.svelte
and Statistics.svelte
can apply their own offsets to render pieces in the right place.
src/containers/Piece.svelte
<script>
...
export let xOffset = 0
export let yOffset = 0
...
function drawCanvas(matrix) {
if (ctx) {
canvasHelper.clearCanvas(ctx, '#000000')
canvasHelper.drawMatrix(ctx, matrix, xOffset, yOffset)
}
}
...
</script>
OK. The size of the rendered pieces is something else we need to leave to props. That way our statistic pieces can be scaled down to fit.
src/containers/Piece.svelte
<script>
...
export let xOffset = 0
export let yOffset = 0
export let scale = 1
...
onMount(() => {
ctx = ref.getContext('2d')
ctx.scale(scale, scale)
})
</script>
Here we simply adjust the canvas scale when we mount the component. It defaults to "1" which is no scale. That way in Statistics.svelte
we can shrink to fit. Update Statistics.svelte
to incorporate those prop changes:
src/containers/Statistics.svelte
<Display>
<div>
<div class="title">Statistics</div>
{#each $stats as stat}
<div class="stat">
<Piece
width={45}
height={20}
piece={tetrominos[stat.id - 1]}
scale={0.5}
xOffset={(4 - tetrominos[stat.id - 1].matrix[0].length) / 2} />
<span>{stat.count.toString().padStart(3, '0')}</span>
</div>
{/each}
</div>
</Display>
And voila!
Final Tweaks
And now a couple final tweaks.
We need to change our NextPiece.svelte
file to use our new offset props:
src/containers/NextPiece.svelte
<Display>
<div>
<span>Next</span>
<Piece
{width}
{height}
piece={$nextPiece}
xOffset={(4 - $nextPiece.matrix[0].length) / 2}
yOffset={1} />
</div>
</Display>
In addition, our overall font size is a little large so we will change that in App.svelte
:
src/App.svelte
<style>
main {
display: flex;
justify-content: center;
padding: 1rem;
font-size: 0.75rem;
}
</style>
Wrapping Up
We have done it. An "almost" fully featured Tetris game built in Svelte. There are still some things we could add (notably the Hard Drop feature). But that's for another day. For now, enjoy your game!
Part One - The Basic Set Up
Part Two - The Board and Pieces
Part Three - Player Movement and Collision Detection
Part Four - SRS Guidelines: Spawn Orientation, Basic Rotation, and Wall Kicks
Part Five - Automatic Falling and Clearing Lines
Part Six - Next Piece (github | preview) | (github | preview), Levels, Lines, Score (github | preview), and Statistics (github | preview)