mihail gaberov

m g

Qui docet, discit.

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.

Memory Card Game
Memory Card Game

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:

    Project Structure
    Project Structure

    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 and Loader can be reused across the application
  • Maintainability: Easier to debug and modify individual components
  • Testing: Components can be tested in isolation
  • Component Organization

  • Card Component
  • CardsGrid Component
  • Loader Component
  • Header/Footer/Subtitle Components
  • 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:
  • Test Configuration:
  • 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:

    // 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:

  • image: (string)
  • id: (string)
  • category: (string)
  • processTurn: (function)
  • isLoading: (boolean)
  • 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:

  • Performance Optimization:
  • Loading State Management:
  • Event Handling:
  • 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:

  • State Management:
  • Game Logic:
  • Data Fetching:
  • Performance Optimization:
  • Persistence:
  • 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 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:

  • Multiple API Sources:
  • Consistent Data Format:
  •   {
          images: [
            {
              id: string,
              image: {
                original: {
                  url: string
                }
              }
            }
          ]
        }

  • Robust Error Handling:
  • Safety Features:
  • Performance Considerations:
  • 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 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:

  • Initial rendering states
  • Loading states
  • Error handling
  • Score tracking
  • Card interaction behavior
  • 2. Test Mocking

    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
  • 3. Testing Best Practices

    Throughout my testing implementation, I followed several best practices:

  • UsingĀ beforeEachĀ andĀ afterEachĀ 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
  • 4. Testing Tools

    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.

    Optimizaitions

    To ensure smooth performance, especially on mobile devices, we implemented several optimization techniques:

  • Response Transformation
  • Network Optimization
  • Mobile-First Considerations
  • Future Improvements

    Several potential enhancements could further optimize the system:

  • API Response Caching
  • Performance Optimizations
  • Reliability Enhancements
  • Analytics and Monitoring
  • 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
  • Technical Excellence
  • Performance First
  • šŸ“šĀ 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!

    You earned a free joke. Rate this article to get it.
    ā† Go home