How to Build a Memory Card Game Using React
Last updated: Nov 28, 2024
Recently, while watching my kid š§š» playing free memory games on her tablet, I noticed her struggling with an overwhelming number of ads and annoying pop-up banners.
This inspired me to build a similar game for her. Since she's currently into anime, I decided to create the game using cute anime-style images.
In this article, I'll walk you through the process of building the game for yourself or your kids š®.
We'll begin by exploring the game features, then cover the tech stack and project structureāboth of which are straightforward. Finally, we'll discuss optimizations and ensuring smooth gameplay on mobile devices š±.
If you want to skip the reading,Ā hereĀ š is the GitHub repository š. AndĀ hereĀ you can see the live demo.
Project Description
In this tutorial, weāll build a challenging memory card game with React that tests your recall abilities. Your goal is to click unique anime images without clicking the same one twice. Each unique click earns you points, but be carefulāclicking an image twice resets your progress.
Game features:
The game will help you test your memory skills while enjoying cute anime pictures. Can you achieve the perfect score?
How to Play
The Tech Stack
Hereās a list of the main technologies weāll be using:
Letās Build the Game
From this point onward I will guide you through the process I followed when building this game.
Project Structure and Architecture
When building this memory card game, I carefully organized the codebase to ensure maintainability, scalability, and clear separation of concerns. Let's explore the structure and the reasoning behind each decision:
Component-Based Architecture
I chose a component-based architecture for several reasons:
Component Organization
CSS Modules Approach
I used CSS Modules (.module.scss files) for several benefits:
Custom Hooks
The hooks directory contains custom hooks like useFetch:
Root Level Files
App.jsx
Performance Considerations
The structure supports performance optimizations:
Scalability
This structure allows for easy scaling:
Development Experience
The structure enhances developer experience:
This architecture proved particularly valuable when optimizing the game for tablet use, as it allowed me to:
Alright, now letās get coding.
Step-by-Step Build Guide
1. Project Setup
Set Up the Development Environment
In order to start with a clean React project, open your terminal app and run the following commands (you may name your project folder as you like ā in my case the name is āmemory-cardā):
npm create vite@latest memory-card -- --template react
cd memory-card
npm install
Install the Required Dependencies
The only dependencies we will use in this project are the hook package from UI.dev (by the way,Ā hereĀ you can find a well-explained article on how rendering in React works).
The other dependency is the famous CSS preprocessor,Ā SASS, that weāll need to be able to write our CSS modules in SASS instead of regular CSS.
npm install @uidotdev/usehooks sass
Configure Vite and Project Setting
When setting up our project, we need to make some specific configuration adjustments to handle SASS warnings and improve our development experience. Here's how you can configure Vitest:
// vitest.config.js
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/setupTests.js'],
css: {
modules: {
classNameStrategy: 'non-scoped'
}
},
preprocessors: {
'**/*.scss': 'sass'
},
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/setupTests.js',
'src/main.jsx',
'src/vite-env.d.ts',
],
},
},
css: {
preprocessorOptions: {
scss: {
quietDeps: true, // Silences SASS dependency warnings
charset: false // Prevents charset warning in recent SASS versions
}
}
}
});
Keep in mind that most of these configurations are auto-generated for you when you create the project with Vite. Hereās whatās going on:
These configurations help create a cleaner development experience by removing unnecessary warning messages in the console, setting up proper test environment configurations, and ensuring SASS/SCSS processing works smoothly.
You might see warnings in your console without these configurations when:
2. Building the Components
Create the Card Component
First, let's create our basic card component that will display individual images:
// src/components/Card/Card.jsx
import React, { useState, useCallback } from "react";
import Loader from "../Loader";
import styles from "./Card.module.scss";
const Card = React.memo(function Card({ imgUrl, imageId, categoryName, processTurn }) {
const [isLoading, setIsLoading] = useState(true);
const handleImageLoad = useCallback(() => {
setIsLoading(false);
}, []);
const handleClick = useCallback(() => {
processTurn(imageId);
}, [processTurn, imageId]);
return (
<div className={styles.container} onClick={handleClick}>
{isLoading && (
<div className={styles.loaderContainer}>
<Loader message="Loading..." />
</div>
)}
<img
src={imgUrl}
alt={categoryName}
onLoad={handleImageLoad}
className={`${styles.image} ${isLoading ? styles.hidden : ''}`}
/>
</div>
);
});
export default Card;
The Card component is a fundamental building block of our game. It's responsible for displaying individual images and handling player interactions. Let's break down its implementation:
Props breakdown:
Component styling:
// src/components/Card/Card.module.scss
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(0, 0, 0, 0.8);
padding: 20px;
font-size: 30px;
text-align: center;
min-height: 200px;
position: relative;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.02);
}
.image {
width: 10rem;
height: auto;
opacity: 1;
transition: opacity 0.3s ease;
&.hidden {
opacity: 0;
}
}
.loaderContainer {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
Usage in the component:
<Card
key={getKey()}
imgUrl={item?.image?.original?.url || ""}
imageId={item?.id}
categoryName={item?.category}
processTurn={(imageId) => processTurn(imageId)}
/>
Key features:
Build the CardsGrid Component
This is our main game component that manages the game state, scoring logic, and card interactions. Let's break down its implementation:
// src/components/CardsGrid/CardsGrid.jsx
import React, { useState, useEffect } from "react";
import { useLocalStorage } from "@uidotdev/usehooks";
import Card from "../Card";
import Loader from "../Loader";
import styles from "./CardsGrid.module.scss";
import useFetch from "../../hooks/useFetch";
function CardsGrid(data) {
// State Management
const [images, setImages] = useState(data?.data?.images || []);
const [clickedImages, setClickedImages] = useLocalStorage("clickedImages", []);
const [score, setScore] = useLocalStorage("score", 0);
const [bestScore, setBestScore] = useLocalStorage("bestScore", 0);
const [isLoading, setIsLoading] = useState(!data?.data?.images?.length);
// Custom hook for fetching images
const { data: fetchedData, fetchData, error } = useFetch();
// Update images when new data is fetched
useEffect(() => {
if (fetchedData?.images) {
setImages(fetchedData.images);
setIsLoading(false);
// Reset clicked images when new batch is loaded
setClickedImages([]);
}
}, [fetchedData]);
// Helper function to update best score
function updateBestScore(currentScore) {
if (currentScore > bestScore) {
setBestScore(currentScore);
}
}
// Core game logic
function processTurn(imageId) {
const newClickedImages = [...clickedImages, imageId];
setClickedImages(newClickedImages);
// If clicking the same image twice, reset everything
if (clickedImages.includes(imageId)) {
// Update the best score if necessary
updateBestScore(score);
setClickedImages([]);
setScore(0);
} else {
// Handle successful card selection
const newScore = score + 1;
setScore(newScore);
// Check for perfect score (all cards clicked once)
if (newClickedImages.length === images.length) {
updateBestScore(newScore);
fetchData();
setClickedImages([]);
} else {
// Shuffle the images
const shuffled = [...images].sort(() => Math.random() - 0.5);
setImages(shuffled);
}
}
}
if (error) {
return <p>Failed to fetch data</p>;
}
if (isLoading) {
return <Loader message="Loading new images..." />;
}
return (
<div className={styles.container}>
{images.map((item) => (
<Card
key={getKey()}
imgUrl={item?.image?.original?.url || ""}
imageId={item?.id}
categoryName={item?.category}
processTurn={(imageId) => processTurn(imageId)}
/>
))}
</div>
);
}
export default React.memo(CardsGrid);
Component styling:
.container {
display: grid;
gap: 1rem 1rem;
grid-template-columns: auto; /* Default: one column for mobile-first */
background-color: #2196f3;
padding: 0.7rem;
cursor: pointer;
}
@media (min-width: 481px) {
.container {
grid-template-columns: auto auto; /* Two columns for tablets and up */
}
}
@media (min-width: 769px) {
.container {
grid-template-columns: auto auto auto; /* Three columns for desktops and larger */
}
}
Key Features Breakdown:
Usage Example:
...
...
function App() {
const { data, loading, error } = useFetch();
if (loading) return <Loader />;
if (error) return <p>Error: {error}</p>;
return (
<div className={styles.container}>
<Header />
<Subtitle />
<CardsGrid data={data} />
<Footer />
</div>
);
}
export default App;
The CardsGrid component serves as the heart of our memory card game, managing:
This implementation provides a smooth gaming experience while maintaining code readability and maintainability through clear separation of concerns and proper state management.
3.Ā Implementing the API Layer
Our game uses a robust API layer with multiple fallback options to ensure reliable image delivery. Let's implement each service and the fallback mechanism.
Set Up the Primary API Service:
// src/services/api/nekosiaApi.js
const NEKOSIA_API_URL = "https://api.nekosia.cat/api/v1/images/catgirl";
export async function fetchNekosiaImages() {
const response = await fetch(
`${NEKOSIA_API_URL}?count=21&additionalTags=white-hair,uniform&blacklistedTags=short-hair,sad,maid&width=300`
);
if (!response.ok) {
throw new Error(`Nekosia API error: ${response.status}`);
}
const result = await response.json();
if (!result.images || !Array.isArray(result.images)) {
throw new Error('Invalid response format from Nekosia API');
}
const validImages = result.images.filter(item => item?.image?.original?.url);
if (validImages.length === 0) {
throw new Error('No valid images received from Nekosia API');
}
return { ...result, images: validImages };
}
Create the First Fallback API Service:
// src/services/api/nekosBestApi.js
const NEKOS_BEST_API_URL = "https://nekos.best/api/v2/neko?amount=21";
export async function fetchNekosBestImages() {
const response = await fetch(NEKOS_BEST_API_URL, {
method: "GET",
mode: "no-cors"
});
if (!response.ok) {
throw new Error(`Nekos Best API error: ${response.status}`);
}
const result = await response.json();
// Transform the response to match our expected format
const transformedImages = result.results.map(item => ({
id: item.url.split('/').pop().split('.')[0], // Extract UUID from URL
image: {
original: {
url: item.url
}
},
artist: {
name: item.artist_name,
href: item.artist_href
},
source: item.source_url
}));
return { images: transformedImages };
}
Create the Second Fallback API Service:
// src/services/api/nekosApi.js
const NEKOS_API_URL = "https://api.nekosapi.com/v3/images/random?limit=21&rating=safe";
export async function fetchNekosImages() {
const response = await fetch(NEKOS_API_URL, {
method: "GET",
});
if (!response.ok) {
throw new Error(`Nekos API error: ${response.status}`);
}
const result = await response.json();
// Transform the response to match our expected format
const transformedImages = result.items.map(item => ({
id: item.id,
image: {
original: {
url: item.image_url
}
}
}));
return { images: transformedImages };
}
Build the API Fallback Mechanism:
// src/services/api/imageService.js
import { fetchNekosiaImages } from "./nekosiaApi";
import { fetchNekosImages } from "./nekosApi";
import { fetchNekosBestImages } from "./nekosBestApi";
export async function fetchImages() {
try {
// Try primary API first
return await fetchNekosiaImages();
} catch (error) {
console.warn("Primary API failed, trying fallback:", error);
// Try first fallback API
try {
return await fetchNekosBestImages();
} catch (fallbackError) {
console.warn("First fallback API failed, trying second fallback:", fallbackError);
// Try second fallback API
try {
return await fetchNekosImages();
} catch (secondFallbackError) {
console.error("All image APIs failed:", secondFallbackError);
throw new Error("All image APIs failed");
}
}
}
}
Use the Image Service:
// src/hooks/useFetch.js
import { useState, useEffect } from "react";
import { fetchImages } from "../services/api/imageService";
export default function useFetch() {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const result = await fetchImages();
setData(result);
} catch (err) {
setError(err.message || 'An error occurred');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
return {
data,
loading,
error,
fetchData,
};
}
Key Features of Our API Implementation:
{
images: [
{
id: string,
image: {
original: {
url: string
}
}
}
]
}
This implementation ensures our game has a reliable source of images while handling potential API failures gracefully. The consistent data format across all APIs makes it easy to switch between them without affecting the game's functionality.
Testing the App
Testing is a crucial part of any application development, and for our Memory Card Game, we implemented a comprehensive testing strategy using modern tools and practices. Let's dive into how we structured our tests and some key testing patterns we used.
Testing Stack
Key Testing Patterns
Testing was a crucial part of ensuring the reliability and maintainability of our Memory Card Game. We implemented a comprehensive testing strategy using React Testing Library and Vitest, focusing on several key areas:
1. Component Testing
I wrote extensive tests for my React components to ensure they render correctly and behave as expected. For example, theĀ CardsGridĀ component, which is the heart of the game, has thorough test coverage including:
2. Test Mocking
To ensure reliable and fast tests, I implemented several mocking strategies:
3. Testing Best Practices
Throughout my testing implementation, I followed several best practices:
4. Testing Tools
The project leverages modern testing tools and libraries:
This comprehensive testing approach helped me catch bugs early, ensured code quality, and made refactoring safer and more manageable.
Optimizaitions
To ensure smooth performance, especially on mobile devices, we implemented several optimization techniques:
Future Improvements
Several potential enhancements could further optimize the system:
This robust implementation ensures that our game remains functional and performant even under adverse network conditions or API unavailability, while still maintaining room for future improvements and optimizations.
Conclusion
Building this Memory Card Game has been more than just creating a fun, ad-free alternative for kidsāit's been an exercise in implementing modern web development best practices while solving a real-world problem.
The project demonstrates how combining thoughtful architecture, robust testing, and reliable fallback mechanisms can result in a production-ready application that's both entertaining and educational.
šļøĀ Key Takeaways
šĀ Learning Outcomes
This project showcases how seemingly simple games can be excellent vehicles for implementing complex technical solutions. From component architecture to API fallbacks, each feature was built with scalability and maintainability in mind, proving that even hobby projects can maintain professional-grade code quality.
š®Ā Moving Forward
While the game successfully achieves its primary goal of providing an ad-free, enjoyable experience, the documented future improvements provide a clear roadmap for evolution. Whether it's implementing additional optimizations or adding new features, the foundation is solid and ready for expansion.
The Memory Card Game stands as a testament to how personal projects can both solve real-world problems and serve as platforms for implementing best practices in modern web development. Feel free to explore the code, contribute, or use it as inspiration for your own projects!
ā Go home