In this series, I take a classic game and rebuild it using Unity’s Entity Component System (ECS).
Play it in WebGL here. For 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
- The number of matches the player has made
- The number of card faces that are currently showing
- 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);
}
}