Learn Svelte By Making A Matching Game
Published Aug 8, 2023
Table of Contents
- Matching Game Setup
- State Management
- Creating The Game
- Matching Cards
- Game Timer
- Game Reset
- Card Flip Effect
- Pausing The Game
Matching Game Setup
Youโre going to make a fun matching game and learn more about state management and reactivity in Svelte.
Try the game in your browser (you might need to enable cookies) and you can also find the code on GitHub.
You can code along using SvelteLab in your browser, or create a SvelteKit project with TypeScript (optional).
Letโs get the base styles out of the way by copying them over to src/app.css
.
@import '@fontsource/poppins';
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--txt-1: hsl(220 10% 98%);
--bg-1: hsl(220 20% 10%);
--bg-2: hsl(220 20% 20%);
--border: hsl(180 100% 50%);
--pulse: hsl(9 100% 64%);
}
html,
body {
height: 100%;
}
body {
display: grid;
place-content: center;
padding: 2rem;
font-family: 'Poppins', sans-serif;
color: var(--txt-1);
background-color: var(--bg-1);
}
h1 {
font-size: 4rem;
text-align: center;
text-transform: capitalize;
}
h1 + button {
width: max-content;
margin-top: 2rem;
margin-inline: auto;
border: 4px solid var(--border);
}
button {
padding: 1.5rem;
font-size: 2rem;
font-weight: 900;
color: inherit;
background: none;
border-radius: 8px;
border: none;
text-transform: uppercase;
cursor: pointer;
}
<script lang="ts">
import '../app.css'
</script>
<svelte:head>
<title>Matching Game</title>
</svelte:head>
<slot />
I got the emoji from Get Emoji by selecting the ones I want and turning them into array I can copy with .split(' ')
.
export const emoji = ["๐", "๐", "๐", "๐", "๐", "๐
", "๐", "๐คฃ", "๐ฅฒ", "๐ฅน", "โบ๏ธ", "๐", "๐", "๐", "๐", "๐", "๐", "๐", "๐ฅฐ", "๐", "๐", "๐", "๐", "๐", "๐", "๐", "๐", "๐คช", "๐คจ", "๐ง", "๐ค", "๐", "๐ฅธ", "๐คฉ", "๐ฅณ", "๐", "๐", "๐", "๐", "๐", "๐", "๐", "โน๏ธ", "๐ฃ", "๐", "๐ซ", "๐ฉ", "๐ฅบ", "๐ข", "๐ญ", "๐ฎโ๐จ", "๐ค", "๐ ", "๐ก", "๐คฌ", "๐คฏ", "๐ณ", "๐ฅต", "๐ฅถ", "๐ฑ", "๐จ", "๐ฐ", "๐ฅ", "๐", "๐ซฃ", "๐ค", "๐ซก", "๐ค", "๐ซข", "๐คญ", "๐คซ", "๐คฅ", "๐ถ", "๐ถโ๐ซ๏ธ", "๐", "๐", "๐ฌ", "๐ซจ", "๐ซ ", "๐", "๐ฏ", "๐ฆ", "๐ง", "๐ฎ", "๐ฒ", "๐ฅฑ", "๐ด", "๐คค", "๐ช", "๐ต", "๐ตโ๐ซ", "๐ซฅ", "๐ค", "๐ฅด", "๐คข", "๐คฎ", "๐คง", "๐ท", "๐ค", "๐ค", "๐ค", "๐ค ", "๐", "๐ฟ", "๐น", "๐บ", "๐คก", "๐ฉ", "๐ป", "๐", "โ ๏ธ", "๐ฝ", "๐พ", "๐ค", "๐", "๐บ", "๐ธ", "๐น", "๐ป", "๐ผ", "๐ฝ", "๐", "๐ฟ", "๐พ"]
The rest of the game is going to be inside +page.svelte
.
<script lang="ts">
import { emoji } from './emoji'
</script>
State Management
You might reach for booleans but you shouldnโt use booleans to represent state if you want to avoid impossible states.
Before you write a line of code think about the possible states the game can be in:
- start (idle)
- playing
- paused
- won
- lost
Representing state with booleans would quickly become a nightmare, but using explicit state you always know what state youโre in.
You can also have intermediatary state like playing.matching
which becomes even more powerful when combined with data attributes for animations since you know the state youโre in.
Iโm going to translate this into code using a type alias but you can use a JavaScript object, or TypeScript enum.
<script lang="ts">
import { emoji } from './emoji'
type State = 'start' | 'playing' | 'paused' | 'won' | 'lost'
let state: State = 'start'
</script>
This avoid spaghetti code and makes it easy to show and hide parts of the UI based on the state youโre in.
Creating The Game
I want to make a dynamic grid to offer more variety which is simpler than you might think.
Iโm going to insert a random unique emoji inside a Set
depending on the grid size, and then duplicate them and shuffle them around.
<script lang="ts">
import { emoji } from './emoji'
type State = 'start' | 'playing' | 'paused' | 'won' | 'lost'
let state: State = 'start'
let size = 20
let grid = createGrid()
function createGrid() {
// only want unique cards
let cards = new Set<string>()
// half because we duplicate the cards
let maxSize = size / 2
while (cards.size < maxSize) {
// pick random emoji
const randomIndex = Math.floor(Math.random() * emoji.length)
cards.add(emoji[randomIndex])
}
// duplicate and shuffle cards
return shuffle([...cards, ...cards])
}
function shuffle<Items>(array: Items[]) {
return array.sort(() => Math.random() - 0.5)
}
</script>
Iโm going to add more things to track the state of the game including the HTML and card styles.
<script lang="ts">
type State = 'start' | 'playing' | 'paused' | 'won' | 'lost'
// game state
let state: State = 'start'
// size of the game grid
let size = 20
// game grid
let grid = createGrid()
// used to check if game is over
let maxMatches = grid.length / 2
// selected cards
let selected: number[] = []
// matched cards
let matches: string[] = []
</script>
{#if state === 'start'}
<h1>Matching game</h1>
<button on:click={() => state = 'playing'}>
Play
</button>
{/if}
{#if state === 'playing'}
<div class="cards">
{#each grid as card, cardIndex}
<button class="card">
<div>{card}</div>
</button>
{/each}
</div>
{/if}
{#if state === 'lost'}
<h1>You lost! ๐ฉ</h1>
<button on:click={() => state = 'playing'}>
Play again
</button>
{/if}
{#if state === 'won'}
<h1>You win! ๐</h1>
<button on:click={() => state = 'playing'}>
Play again
</button>
{/if}
<style>
.cards {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.4rem;
}
.card {
height: 140px;
width: 140px;
font-size: 4rem;
background-color: var(--bg-2);
&.selected {
border: 4px solid var(--border);
}
}
</style>
Matching Cards
Using selected
we can keep track of the selected variables and trigger matchCards()
using reactive statements when selected.length === 2
.
If the cards match we can update matches
and if maxMatches === matches.length
then the game is won.
<script lang="ts">
// ...
function selectCard(cardIndex: number) {
selected = selected.concat(cardIndex)
}
function matchCards() {
// array destructuring can have any name for the values
const [first, second] = selected
if (grid[first] === grid[second]) {
matches = matches.concat(grid[first])
}
// clear selected
setTimeout(() => selected = [], 300)
}
function gameWon() {
state = 'won'
}
$: selected.length === 2 && matchCards()
$: maxMatches === matches.length && gameWon()
</script>
{#if state === 'playing'}
<div class="cards">
{#each grid as card, cardIndex}
{@const isSelected = selected.includes(cardIndex)}
{@const isSelectedOrMatch = selected.includes(cardIndex) || matches.includes(card)}
{@const match = matches.includes(card)}
<button
class="card"
class:selected={isSelected}
disabled={isSelectedOrMatch}
on:click={() => selectCard(cardIndex)}
>
<div class:match>{card}</div>
</button>
{/each}
</div>
{/if}
<style>
.card {
/* ... */
& .match {
transition: opacity 0.3s ease-out;
opacity: 0.4;
}
}
</style>
Local constants are great because they let us define variables inside of an each
block.
Letโs show the matched cards.
{#if state === 'playing'}
<div class="matches">
{#each matches as match}
<div>{match}</div>
{/each}
</div>
<!-- .. -->
{/if}
<style>
/* ... */
.matches {
display: flex;
gap: 1rem;
margin-block: 2rem;
font-size: 3rem;
}
</style>
Game Timer
Letโs add a game timer โ when it reaches zero, the game is over.
<script lang="ts">
// ...
let timerId: number | null = null
let time = 60
// ...
function startGameTimer() {
function countdown() {
state !== 'paused' && (time -= 1)
}
timerId = setInterval(countdown, 1000)
}
function gameLost() {
state = 'lost'
}
// ...
$: if (state === 'playing') {
// in case you pause the game
!timerId && startGameTimer()
}
$: time === 0 && gameLost()
</script>
{#if state === 'playing'}
<h1 class="timer" class:pulse={time <= 10}>
{time}
</h1>
<!-- .. -->
{/if}
<style>
/* ... */
.timer {
transition: color 0.3s ease;
}
.pulse {
color: var(--pulse);
animation: pulse 1s infinite ease;
}
@keyframes pulse {
to {
scale: 1.4;
}
}
</style>
Game Reset
Regardless if you win or lose the game we have to reset the board.
<script lang="ts">
// ...
function resetGame() {
timerId && clearInterval(timerId)
grid = createGrid()
maxMatches = grid.length / 2
selected = []
matches = []
timerId = null
time = 60
}
function gameWon() {
state = 'won'
resetGame()
}
function gameLost() {
state = 'lost'
resetGame()
}
</script>
Card Flip Effect
To achieve the card flip animation we can use a CSS 3D transform.
<button
class="card"
class:selected={isSelected}
class:flip={isSelectedOrMatch}
disabled={isSelectedOrMatch}
on:click={() => selectCard(cardIndex)}
>
<div class="back" class:match>
{card}
</div>
</button>
<style>
.card {
height: 140px;
width: 140px;
font-size: 4rem;
background-color: var(--bg-2);
transition: rotate 0.3s ease-out;
transform-style: preserve-3d;
&.selected {
border: 4px solid var(--border);
}
&.flip {
rotate: y 180deg;
pointer-events: none;
}
& .back {
position: absolute;
inset: 0;
display: grid;
place-content: center;
backface-visibility: hidden;
rotate: y 180deg;
}
& .match {
transition: opacity 0.3s ease-out;
opacity: 0.4;
}
}
</style>
Pausing The Game
To pause the game we can listen for a keypress on the window and toggle the paused state.
<script lang="ts">
// ...
function pauseGame(e: KeyboardEvent) {
if (e.key === 'Escape') {
switch (state) {
case 'playing':
state = 'paused'
break
case 'paused':
state = 'playing'
break
}
}
}
</script>
<svelte:window on:keydown={pauseGame} />
{#if state === 'paused'}
<h1>Game paused</h1>
{/if}
The game timer is already paused because it only does a tick if the game isnโt in the paused
state.
Thatโs it! ๐
As an exercise try breaking the game into separate components and you could add difficulty levels for the player to choose from.
Hope you had as much fun making the game as I have and learned something along the way.