Skip to content

Memory Game in Unity ECS

In this series, I take a classic game and rebuild it using Unity’s Entity Component System (ECS)

Play it in WebGL hereFor full source code of this project, checkout my github

Table of Contents

Overview

Memory (sometimes called Match) is a classic game in which pairs of cards with matching symbols are displayed face down.  The player selects 2 cards to turn over and, if they match, the cards disappear.  If they don’t match, the cards are once again flipped over to face downward.  This game challenges the player to remember the position and symbols of the cards in order to find their match. 

Card Data

Each card has a graphical symbol and a value associated with it.  The symbol is used as a visual identifier for the player and the value is used internally, hidden from the player, to determine if the 2 selected cards match.  Matching cards will have the same value

public struct Card : IComponentData
{
    public int value;
    public Entity face;
} 

The above structure allows us to work with card data in our systems, but we also need a class to use with our card prefab, giving us a way to do setup through the Unity inspector window, instead of code.

[DisallowMultipleComponent]
[RequiresEntityConversion]
public class CardAuthoring : MonoBehaviour, IConvertGameObjectToEntity
{
    public int value;
    public GameObject face;

    public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem)
    {
        Entity faceEntity = conversionSystem.GetPrimaryEntity(face);
        dstManager.AddComponentData<Card>(entity, new Card() { value = value, face = faceEntity });
    }
} 

Game State Data

In order to control and record what is happening in the game, we’ll need a data structure to maintain

  1. The number of matches the player has made
  2. The number of card faces that are currently showing
  3. How long is takes for a card to flip over
public struct GameSettings : IComponentData
{
    public int matches;
    public int facesShowing;
    public float secondsToTurn;
} 

Building the Card Grid

We will generate pairs of cards, assigning each member of the pair the same value and the same material (the card’s graphical symbol and visual identifier for the player).  We will also place some markers in the scene in order to help us with card positioning.  

public class MatchingGrid : MonoBehaviour, IConvertGameObjectToEntity
{
    public Transform m_minPos;
    public Transform m_maxPos;
    
    public CardAuthoring cardGameObject;
    public int pairs = 10;
    public Material[] m_cardMaterials;
    
    NativeList<float3> cardPositions;
} 
public class MatchingGrid : MonoBehaviour, IConvertGameObjectToEntity
{
    public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem)
    {
        cardPositions = new NativeList<float3>(Allocator.Temp);
        int numCards = pairs * 2;
        
        // try to balance between rows and cols based on the number of cards required
        int cols = (int)math.round(math.sqrt(numCards));
        float width = (m_maxPos.position.x - m_minPos.position.x) / (cols);
        float height = (m_maxPos.position.y - m_minPos.position.y) / (cols);

        int row = -1;
        for (int i = 0; i < numCards; i++)
        {
            if (i % cols == 0)
            {
                // fill the column then move to the next row
                row += 1;
            }

            // create card positions based on the number of columns. Offset by the min position
            float3 pos = m_minPos.position;
            pos.x += (i % cols) * width;
            pos.y += (row) * height;
            cardPositions.Add(pos);                
        }

        // create the card pairs, assigning the same value and material to each member of the pair
        for (int i = 0; i < pairs; i++)
        {
            int value = i % m_cardMaterials.Length;
            CreateCard(ExtractPosition(), value, m_cardMaterials[value], dstManager, conversionSystem);
            CreateCard(ExtractPosition(), value, m_cardMaterials[value], dstManager, conversionSystem);
        }

        cardPositions.Dispose();
    }
        

    float3 ExtractPosition()
    {
        // get a random position from the list and remove it so it can't be reused
        int index = UnityEngine.Random.Range(0, cardPositions.Length);
        float3 pos = cardPositions[index];
        cardPositions.RemoveAt(index);
        return pos;
    }
    
    Entity CreateCard(float3 pos, int value, Material mat, EntityManager dstManager, GameObjectConversionSystem conversionSystem)
    {
        // extract the card from the gameobject
        GameObjectConversionSettings settings = GameObjectConversionSettings.FromWorld(dstManager.World, conversionSystem.BlobAssetStore);
        Entity card = GameObjectConversionUtility.ConvertGameObjectHierarchy(cardGameObject.gameObject, settings);            
        
        // create the card
        card = dstManager.Instantiate(card);

        quaternion startingRot = quaternion.identity;
        
        // set the card value and face
        Card cardData = dstManager.GetComponentData<Card>(card);
        cardData.value = value;
        RenderMesh cardFace = dstManager.GetSharedComponentData<RenderMesh>(cardData.face);
        cardFace.material = mat;
        dstManager.SetSharedComponentData<RenderMesh>(cardData.face, cardFace);
        dstManager.SetComponentData<TargetRotation>(card, new TargetRotation { target = startingRot });
        dstManager.SetComponentData<Card>(card, cardData);

        // set position and bounding box
        dstManager.SetComponentData<Translation>(card, new Translation { Value = pos });
        BoundingBox box = dstManager.GetComponentData<BoundingBox>(card);
        box.aabb.Center = pos;
        dstManager.SetComponentData<BoundingBox>(card, box);
        return card;
    }
} 

Card Clicking System

Now that we have our card data and the creation of our cards, we can setup our rules to click the cards in order for the player to play the game.

public class CardClickingSystem : SystemBase
{
    Entity[] selections;
    bool doComparison;
    float timer;
    GameHandler m_game;

    protected override void OnCreate()
    {
        base.OnCreate();
        selections = new Entity[2]; // max of 2 cards show at once
        m_game = GameObject.FindObjectOfType<GameHandler>();
    }

    protected override void OnUpdate()
    {
        Entity settingsEntity = EntityManager.CreateEntityQuery(typeof(GameSettings)).GetSingletonEntity();
        GameSettings settings = EntityManager.GetComponentData<GameSettings>(settingsEntity);

        float dt = World.Time.DeltaTime;
        if (doComparison)
        {
            DoCardComparison(settingsEntity, ref settings);
        }

        if (Input.GetMouseButtonDown(0))
        {
            // handle mouse click
            
            if (settings.facesShowing >= 2)
            {
                // there's already 2 cards showing, ignore to click
                return;
            }

            // check which card was clicked based on the click position
            float3 pos = Camera.main.ScreenToWorldPoint(Input.mousePosition);
            pos.z = 0;
            Entity clickedCard = Entity.Null;
            Entities.WithAll<Card>().ForEach((Entity cardEnt, in BoundingBox box) =>
            {                    
                if (box.aabb.Contains(pos))
                {
                    // the card that was clicked
                    clickedCard = cardEnt;                        
                }
            }).Run();

            if (clickedCard != Entity.Null)
            {
                // we have a clicked card, update the game state
                selections[settings.facesShowing] = clickedCard;
                settings.facesShowing += 1;
                EntityManager.SetComponentData<GameSettings>(settingsEntity, settings);

                // flip the card over
                EntityManager.SetComponentData<TargetRotation>(clickedCard, new TargetRotation { target = quaternion.Euler(0, math.radians(180), 0) });
                EntityManager.SetComponentData<Timer>(clickedCard, new Timer { curr = 0, max = settings.secondsToTurn });
                if (settings.facesShowing == 2)
                {
                    // they have 2 cards showing, we'll need to do the comparison
                    timer = settings.secondsToTurn;
                    doComparison = true;
                }
            }
        }
    }

    void DoCardComparison(Entity settingsEntity, ref GameSettings settings)
    {
        float dt = World.Time.DeltaTime;
        
        timer -= dt;
        if (timer <= 0)
        {
            doComparison = false;
            settings.facesShowing = 0;
            EntityManager.SetComponentData<GameSettings>(settingsEntity, settings);

            // comparison time
            ComponentDataFromEntity<Card> cards = GetComponentDataFromEntity<Card>(true);
            int value = cards[selections[0]].value;
            bool cardsMatch = true;
            for (int i = 1; i < selections.Length; i++)
            {
                if (cards[selections[i]].value != value)
                {
                    cardsMatch = false;
                    break;
                }
            }

            if (cardsMatch)
            {
                // they match, remove the matches
                for (int i = 0; i < selections.Length; i++)
                {
                    EntityManager.DestroyEntity(selections[i]);
                }
            }
            else
            {
                // they don't match, flip them back over
                for (int i = 0; i < selections.Length; i++)
                {
                    EntityManager.SetComponentData<TargetRotation>(selections[i],
                        new TargetRotation { target = quaternion.identity });
                    EntityManager.SetComponentData<Timer>(selections[i],
                        new Timer { curr = 0, max = settings.secondsToTurn });
                }
            }

            // check if all the cards have been matched
            if (EntityManager.CreateEntityQuery(typeof(Card)).CalculateEntityCount() == 0)
            {
                m_game.ResetGame();
            }
        }
    }
} 

Card Turning and Game Resetting

Lastly, we tie everything together with a card turning system and the resetting of the card grid when all matches have been made

public class RotateToTargetSystem : SystemBase
{
    protected override void OnUpdate()
    {
        float dt = EntityManager.World.Time.DeltaTime;

        Entities.ForEach((ref Rotation rotation, ref Timer timer, in TargetRotation targetRot) =>
        {
            rotation.Value = math.slerp(rotation.Value, targetRot.target, timer.curr / timer.max);
            timer.curr += dt;
            timer.curr = math.min(timer.curr, timer.max);
        }).Schedule();
    }
} 
public class GameHandler : MonoBehaviour
{
    public MatchingGrid m_matchingGrid;

    public void ResetGame()
    {
        Instantiate(m_matchingGrid);
    }
}