How to Build a Memory Card Game Using React
How to Build a Memory Card Game Using React ๊ด๋ จ
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 (mihailgaberov/memory-card
) ๐ 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:
- ๐ฏ Dynamic gameplay that challenges your memory
- ๐ Cards shuffle after each click to increase difficulty
- ๐ Score tracking with best score persistence
- ๐บ Adorable anime images from The Nekosia API
- โจ Smooth loading transitions and animations
- ๐ฑ Responsive design for all devices
- ๐จ Clean, modern UI
The game will help you test your memory skills while enjoying cute anime pictures. Can you achieve the perfect score?
How to Play
- Click on any card to start
- Remember which cards you've clicked
- Try to click all cards exactly once
- Watch your score grow with each unique selection
- Then keep playing to try to beat your best score
The Tech Stack
Hereโs a list of the main technologies weโll be using:
- NPM โ A package manager for JavaScript that helps manage dependencies and scripts for the project.
- Vite โ A build tool that provides a fast development environment, particularly optimized for modern web projects.
- React โ A popular JavaScript library for building user interfaces, enabling efficient rendering and state management.
- CSS Modules โ A styling solution that scopes CSS to individual components, preventing style conflicts and ensuring maintainability.
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:
- Modularity: Each component is self-contained with its own logic and styles
- Reusability: Components like
Card
andLoader
can be reused across the application - Maintainability: Easier to debug and modify individual components
- Testing: Components can be tested in isolation
Component Organization
- Separated into its own directory because it's a core game element
- Contains both JSX and SCSS modules for encapsulation
- Handles individual card rendering, loading states, and click events
- Manages the game board layout
- Handles card shuffling and distribution
- Controls the responsive grid layout for different screen sizes
- Reusable loading indicator
- Improves user experience during image loading
- Can be used by any component that needs loading states
- Structural components for app layout
- Header displays game title and scores
- Footer shows copyright and version info
- Subtitle provides game instructions
CSS Modules Approach
I used CSS Modules (.module.scss
files) for several benefits:
- Scoped Styling: Prevents style leaks between components
- Name Collisions: Automatically generates unique class names
- Maintainability: Styles are co-located with their components
- SCSS Features: Leverages SCSS features while keeping styles modular
Custom Hooks
The hooks
directory contains custom hooks like useFetch:
- Separation of Concerns: Isolates data fetching logic
- Reusability: Can be used by any component needing image data
- State Management: Handles loading, error, and data states
- Performance: Implements optimizations like image size control
Root Level Files
App.jsx:
- Acts as the application's entry point
- Manages global state and routing (if needed)
- Coordinates component composition
- Handles top-level layouts
Performance Considerations
The structure supports performance optimizations:
- Code Splitting: Components can be lazy-loaded if needed
- Memoization: Components can be memoized effectively
- Style Loading: CSS Modules enable efficient style loading
- Asset Management: Images and resources are properly organized
Scalability
This structure allows for easy scaling:
- New features can be added as new components
- Additional hooks can be created for new functionality
- Styles remain maintainable as the app grows
- Testing can be implemented at any level
Development Experience
The structure enhances developer experience:
- Clear file organization
- Intuitive component locations
- Easy to find and modify specific features
- Supports efficient collaboration
This architecture proved particularly valuable when optimizing the game for tablet use, as it allowed me to:
- Easily identify and optimize performance bottlenecks
- Add tablet-specific styles without affecting other devices
- Implement loading states for better mobile experience
- Maintain clean separation between game logic and UI components
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:
- SASS Configuration:
quietDeps: true
: This silences the warnings about deprecated dependencies in SASS modules. Particularly useful when working with third-party SASS/SCSS files.charset: false
: Prevents the "@charset" warning that appears in newer versions of SASS when using special characters in your stylesheets.
- Test Configuration:
globals: true
: Makes test functions globally available in test filesenvironment: 'jsdom'
: Provides a DOM environment for testingsetupFiles
: Points to our test setup file
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:
- Using SASS/SCSS features or importing SASS files
- Running tests that require DOM manipulation
- Using special characters in your stylesheets
2. Building the Components
Create the Card Component
First, let's create our basic card component that will display individual images:
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
image
: (string)- The URL of the image to be displayed thatโs received from our API service.
- Itโs used directly in the img tag's src attribute.
id
: (string)- Unique identifier for each card thatโs critical for tracking which cards have been clicked.
- Itโs passed to the
processTurn
callback when a card is clicked.
category
: (string)- Describes the type of image (for example, "anime", "neko"), and itโs used in the alt attribute for better accessibility.
- It helps with SEO and screen readers.
processTurn
: (function)- Callback function passed from the parent component that handles the game logic when a card is clicked.
- It also manages score updates and game state changes and determines if a card has been clicked before.
isLoading
: (boolean)- Controls whether to show a loading state. When true, it displays a Loader component instead of the image.
- It improves the user experience during image loading.
Component styling:
//
.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:
- Performance Optimization:
- Uses
React.memo
to prevent unnecessary re-renders - Implements
useCallback
for event handlers - Manages loading state internally for better UX
- Uses
- Loading State Management:
- Internal
isLoading
state tracks image loading - Shows a Loader component with a message while loading
- Hides the image until it's fully loaded using CSS classes
- Internal
- Event Handling:
handleImageLoad
: Manages the loading state transitionhandleClick
: Processes player moves via theprocessTurn
callback
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:
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:
- State Management:
- Uses
useState
for component-level state - Implements
useLocalStorage
for persistent game data:clickedImages
: Tracks which cards have been clickedscore
: Current game scorebestScore
: Highest score achieved
- Manages loading state for image fetching
- Shuffle the cards
- Uses
- Game Logic:
processTurn
: Handles player moves- Tracks duplicate clicks
- Updates scores
- Manages perfect score scenarios
updateBestScore
: Updates high score when necessary- Automatically fetches new images when a round is completed
- Data Fetching:
- Uses custom
useFetch
hook for image data - Handles loading and error states
- Updates images when new data is fetched
- Uses custom
- Performance Optimization:
- Component wrapped in
React.memo
- Efficient state updates
- Responsive grid layout
- Component wrapped in
- Persistence:
- Game state persists across page reloads
- Best score tracking
- Current game progress saving
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:
- Game state and logic
- Score tracking
- Card interactions
- Image loading and display
- Responsive layout
- Data persistence
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 Lay
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:
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:
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:
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:
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:
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:
- Primary API (Nekosia): Provides high-quality anime images
- First Fallback (Nekos Best): Includes artist information
- Second Fallback (Nekos): Simple and reliable backup
- All APIs transform their responses to match our expected format:
{
"images": [
{
"id": "string",
"image": {
"original": {
"url": "string"
}
}
}
]
}
- Validates API responses
- Checks for valid image URLs
- Provides detailed error messages
- Graceful fallback mechanism
- Safe content filtering (
rating=safe
) - Image count limitation (21 images)
- URL validation
- Response format validation
- Optimized image sizes
- Filtered content tags
- Efficient data transformation
- Minimal API calls
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
- Vitest: Our primary testing framework, chosen for its speed and seamless integration with Vite
- React Testing Library: For testing React components with a user-centric approach
- @testing-library/user-event: For simulating user interactions
- jsdom: For creating a DOM environment in our tests
Key Testing Patterns
Testing was a crucial part of ensuring the reliability and maintainability of this Memory Card Game. I implemented a comprehensive testing strategy using React Testing Library and Vitest, focusing on several key areas:
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:
- Initial rendering states
- Loading states
- Error handling
- Score tracking
- Card interaction behavior
To ensure reliable and fast tests, I implemented several mocking strategies:
- Local storage operations using useLocalStorage hook
- API calls using the
useFetch
hook - Event handlers and state updates
Throughout my testing implementation, I followed several best practices:
- Using
beforeEach
andafterEach
hooks to reset state between tests - Testing user interactions using
fireEvent
from React Testing Library - Writing tests that resemble how users interact with the app
- Testing both success and error scenarios
- Isolating tests using proper mocking
The project leverages modern testing tools and libraries:
- Vitest: As the test runner
- React Testing Library: For testing React components
- @testing-library/jest-dom: For enhanced DOM testing assertions
- @testing-library/user-event: For simulating user interactions
This comprehensive testing approach helped me catch bugs early, ensured code quality, and made refactoring safer and more manageable.
Optimizations
To ensure smooth performance, especially on mobile devices, we implemented several optimization techniques:
- Response Transformation
- Standardized data format across all APIs
- Efficient ID extraction from URLs
- Structured image metadata for quick access
- Network Optimization
- Using
no-cors
mode where appropriate to handle CORS issues efficiently - Error handling with specific status codes for better debugging
- Consistent response structure across all API implementations
- Using
- Mobile-First Considerations
- Optimized image loading strategy
- Efficient error handling to prevent unnecessary retries
- Streamlined data transformation to reduce processing overhead
Future Improvements
There are a few ways that we could further improve this project:
- API Response Caching
- Implement local storage caching for frequently used images
- Add cache invalidation strategy for fresh content
- Implement progressive image loading
- Performance Optimizations
- Add image lazy loading for better initial load time
- Implement request queuing for better bandwidth management
- Add response compression for faster data transfer
- Reliability Enhancements
- Add API health checking before attempts
- Implement retry mechanisms with exponential backoff
- Add circuit breaker pattern for failing APIs
- Analytics and Monitoring
- Track API success rates
- Monitor response times
- Implement automatic API switching based on performance metrics
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
- User-Centric Development
- Started with a clear problem (ad-filled games affecting user experience)
- Implemented features that enhance gameplay without interruptions
- Maintained focus on performance and reliability across devices
- Technical Excellence
- Leveraged modern React patterns and hooks for clean, maintainable code
- Implemented comprehensive testing strategy ensuring reliability
- Created a robust API fallback system for uninterrupted gameplay
- Performance First
- Adopted mobile-first approach with responsive design
- Optimized image loading and handling
- Implemented efficient state management and caching strategies
๐ 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 mihailgaberov/memory-card
, contribute, or use it as inspiration for your own projects!