Updated

Solitaire

When I was first trying to learn Unity, C#, and programming design patterns, I chose to do so with Solitaire. It would not require any designing on my part, since I could download premade assets - leaving me to focus nearly 100% on code and execution.

The result is a fully functional game of Solitaire, very similar to the original game released with every Windows operating system. You can play here in WebGL format.

A laptop

In this article I will discuss the various design patterns I used to build the game, as well as the C# code implemented within the Unity framework.

Input

One of the first challenges I tackled was how to process user input. I started by creating an InputHandler.cs class that would live inside the game as a singleton object. Its job is to listen to user input (mouse clicks and drags), decide whether that input should result in action (i.e. check that the mouse has moved a certain minimum distance before determining whether the input was a click or a drag) and then pass that information on to a new object called a MouseDown.

void Update()
{
    if (!_inputAllowed || _settingsPanel.activeSelf) return;
    
    SetCurrentMouseWorldPosition();

    if (Input.GetMouseButtonDown(0)) _mouseDown = new MouseDown(_currentMouseWorldPosition);
    
    if (Input.GetMouseButtonUp(0))
    {
        if (!_isDragging && !IsDoubleClick()) ProcessClick();
        else if (!_isDragging && IsDoubleClick()) ProcessDoubleClick();
        else ProcessRelease();
        _isDragging = false;
        _lastClickTime = Time.time;
    }

    if (Input.GetMouseButton(0)) _isDragging = MovedEnoughToBeConsideredDragging();
    if (_isDragging) ProcessDrag();
}

Originally, I had included the logic in the MouseDown class inside the InputHandler, but chose to refactor it into its own logic to better follow the single-responsibility principle. The instantiated MouseDown object does not concern itself with whether input occurred or how it should be registered - its job is to determine what to do with the registered input given to it by the InputHandler.

To determine whether the input should result in an action, I created two interfaces - IDraggable and IClickable - and any objects that implement these interfaces agree to provide methods such as Click(), Release(), or Drag(). These interfaces are implemented by various GameObjects - for example the Stock (which is the official name for the deck of cards from which you draw in Solitaire) implements IClickable, and the individual cards implement both IClickable and IDraggable. Based on the GameObject intersecting the user's click or drag, the MouseDown class will attempt to call the appropriate methods on those objects.

public void DoubleClick(Vector2 currentMousePosition)
{
    if (_collider == null) return;
    if (_collider.TryGetComponent(out IClickable doubleClickedSprite)) doubleClickedSprite.DoubleClick();
}

Data

The data for each card is represented through the CardInfo class, which simply includes a suit and a number. When each game begins, a deck of 52 CardInfo objects are instantiated inside the Deck class, their order is randomized, and then they are passed on to the Stock game object. The Stock then "deals" the cards out, by instantiating PlayingCard objects, assigning them a CardInfo data property, and giving them a destination stack to be dealt to.

One of the drawbacks of Unity's architecture is that I could not directly assign the CardInfo property upon instantiating a PlayingCard - as there is no constructor for GameObjects. This has to be done sequentially in the code, which, in my opinion, is not ideal and unnecessarily exposes a setter.

PlayingCard DrawAndSetNewPlayingCard(bool turnFaceUp)
{
    PlayingCard newCard =
        Instantiate(cardPrefab, transform.position, Quaternion.identity);
    newCard.SetCard(DrawCard(), turnFaceUp);
    return newCard;
}

The Cards

The center of attention in the code is the PlayingCard class. This class orchestrates the majority of the functionality of each card. Originally its scope was even larger but through some refactoring it has been reduced to exclude any animation, visuals, or sound via the CardVisuals, CardAnimation, and CardSounds classes. Instead, it's primary purpose is to interpret the input information passed along by a MouseDown call, make requests to the stacks to which the user has attempted to move the card, and update its position and data appropriately based on the response.

public void Release(Collider2D colliderReleasedOn)
{
    if (!_isBeingDragged) return;
    _isBeingDragged = false;
    SetLayer(0);

    StackTransfer stackTransfer = new StackTransfer(this, colliderReleasedOn);
    if (stackTransfer.IsApproved)
    {
        stackTransfer.Process();
        OnCardPlaced?.Invoke();
    }
    else ResetPosition();
}

The remaining card classes provide UI elements to this functionality. The CardAnimation class merely tweens the card's movement to its desired position, the CardSounds class listens to events on the card and plays the appropriate sound, and the CardVisuals class displays the card face down or face up and fetches the appropriate sprite image that reflects the card's suit and rank.

The Stacks

Each stack type, including the TableauStack, the WasteStack, and the FoundationStack, provide logic that determines whether a card can be added to it or not. For example, the FoundationStack class will verify that the card being added is the next rank and same suit as the existing cards. They do this through shared methods implemented by their base abstract Stack class. The stacks also manage a list of all the cards in them - to aid in determining whether certain moves are allowed by the user.

public override bool CanAddCard(PlayingCard card) => 
  CardStack.Count == 0 ? IsAce(card) : IsSequentialSameSuit(card);
  
static bool IsAce(PlayingCard card) => card.CardInfo.Rank == 0;

bool IsSequentialSameSuit(PlayingCard card)
{
  return card.CardInfo.Suit == CardStack.Last().CardInfo.Suit &&
         card.CardInfo.Rank == CardStack.Last().CardInfo.Rank + 1;
}

CardActions

Encapsulating various player actions in the game using the command pattern allowed better management of gameplay and enabled moves to be undone. When the player drags a card onto a stack and releases the card, a StackTransfer request object is created (which is a subclass of the abstract CardActions class). The StackTransfer can then be approved or denied by the stack depending on whether the move is legal or not, and then processed accordingly.

public override void Process()
{
    if (!IsApproved || _isProcessed) return;
  
    _newStack.Transfer(_card, _card.CurrentStack);
  
    _isProcessed = true;
    base.Process();
}

bool CanBeAddedToStack => _newStack != null && _newStack.CanAddCard(_card);

By grouping actions into their own logic and classes, every subclass of CardAction invokes an event which enables it to be recorded by the CardActionHistory singleton game object. Later, if the player has enabled the option, the move can then be undone by popping the action from the stack and executing its Undo() method.

public void UndoAction()
{
    if (_actions.Count == 0 || !_undoAllowed) return;
    _actions.Pop().Undo();
    UpdateButtonInteractable();
}

void AddAction(CardAction action)
{
    _actions.Push(action);
    UpdateButtonInteractable();
}

Closing Thoughts

I put a lot of work into this game and getting the code to feel clean. While I had the game up and running in a weekend, the real learning opportunity for me was when I then took the time to refactor all of the code into cleaner, more readable code. It gave me a chance to learn about various design patterns, including Command, Observer, Strategy, and many others, without having to worry about game design (since Klondike Solitaire is pretty much already made).

One day, to hone my JavaScript skills a little more, I might take the time to develop the same game in React or similar framework, without the Unity game engine. I'd be interested to learn what challenges I might face in that scenario, and how I might approach those problems in my code and from an architectural standpoint.