Learn Svelte By Making A Matching Game

Published Aug 8, 2023

Table of Contents

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.

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;
}
src/routes/+layout.svelte
<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(' ').

src/routes/emoji.ts
export const emoji = ["๐Ÿ˜€", "๐Ÿ˜ƒ", "๐Ÿ˜„", "๐Ÿ˜", "๐Ÿ˜†", "๐Ÿ˜…", "๐Ÿ˜‚", "๐Ÿคฃ", "๐Ÿฅฒ", "๐Ÿฅน", "โ˜บ๏ธ", "๐Ÿ˜Š", "๐Ÿ˜‡", "๐Ÿ™‚", "๐Ÿ™ƒ", "๐Ÿ˜‰", "๐Ÿ˜Œ", "๐Ÿ˜", "๐Ÿฅฐ", "๐Ÿ˜˜", "๐Ÿ˜—", "๐Ÿ˜™", "๐Ÿ˜š", "๐Ÿ˜‹", "๐Ÿ˜›", "๐Ÿ˜", "๐Ÿ˜œ", "๐Ÿคช", "๐Ÿคจ", "๐Ÿง", "๐Ÿค“", "๐Ÿ˜Ž", "๐Ÿฅธ", "๐Ÿคฉ", "๐Ÿฅณ", "๐Ÿ˜", "๐Ÿ˜’", "๐Ÿ˜ž", "๐Ÿ˜”", "๐Ÿ˜Ÿ", "๐Ÿ˜•", "๐Ÿ™", "โ˜น๏ธ", "๐Ÿ˜ฃ", "๐Ÿ˜–", "๐Ÿ˜ซ", "๐Ÿ˜ฉ", "๐Ÿฅบ", "๐Ÿ˜ข", "๐Ÿ˜ญ", "๐Ÿ˜ฎโ€๐Ÿ’จ", "๐Ÿ˜ค", "๐Ÿ˜ ", "๐Ÿ˜ก", "๐Ÿคฌ", "๐Ÿคฏ", "๐Ÿ˜ณ", "๐Ÿฅต", "๐Ÿฅถ", "๐Ÿ˜ฑ", "๐Ÿ˜จ", "๐Ÿ˜ฐ", "๐Ÿ˜ฅ", "๐Ÿ˜“", "๐Ÿซฃ", "๐Ÿค—", "๐Ÿซก", "๐Ÿค”", "๐Ÿซข", "๐Ÿคญ", "๐Ÿคซ", "๐Ÿคฅ", "๐Ÿ˜ถ", "๐Ÿ˜ถโ€๐ŸŒซ๏ธ", "๐Ÿ˜", "๐Ÿ˜‘", "๐Ÿ˜ฌ", "๐Ÿซจ", "๐Ÿซ ", "๐Ÿ™„", "๐Ÿ˜ฏ", "๐Ÿ˜ฆ", "๐Ÿ˜ง", "๐Ÿ˜ฎ", "๐Ÿ˜ฒ", "๐Ÿฅฑ", "๐Ÿ˜ด", "๐Ÿคค", "๐Ÿ˜ช", "๐Ÿ˜ต", "๐Ÿ˜ตโ€๐Ÿ’ซ", "๐Ÿซฅ", "๐Ÿค", "๐Ÿฅด", "๐Ÿคข", "๐Ÿคฎ", "๐Ÿคง", "๐Ÿ˜ท", "๐Ÿค’", "๐Ÿค•", "๐Ÿค‘", "๐Ÿค ", "๐Ÿ˜ˆ", "๐Ÿ‘ฟ", "๐Ÿ‘น", "๐Ÿ‘บ", "๐Ÿคก", "๐Ÿ’ฉ", "๐Ÿ‘ป", "๐Ÿ’€", "โ˜ ๏ธ", "๐Ÿ‘ฝ", "๐Ÿ‘พ", "๐Ÿค–", "๐ŸŽƒ", "๐Ÿ˜บ", "๐Ÿ˜ธ", "๐Ÿ˜น", "๐Ÿ˜ป", "๐Ÿ˜ผ", "๐Ÿ˜ฝ", "๐Ÿ™€", "๐Ÿ˜ฟ", "๐Ÿ˜พ"]

The rest of the game is going to be inside +page.svelte.

src/routes/+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.

src/routes/+page.svelte
<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.

src/routes/+page.svelte
<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.

src/routes/+page.svelte
<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.

src/routes/+page.svelte
<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.

src/routes/+page.svelte
{#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.

src/routes/+page.svelte
<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.

src/routes/+page.svelte
<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.

src/routes/+page.svelte
<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.

src/routes/+page.svelte
<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.

Support

You can subscribe on YouTube, or consider becoming a patron if you want to support my work.

Patreon
Found a mistake?

Every post is a Markdown file so contributing is simple as following the link below and pressing the pencil icon inside GitHub to edit it.

Edit on GitHub