How we made a card game without screens at Yfrit Games

“A card game without screens? What do you mean? Did you code with your monitor turned off or something?”

Well, of course not. What I mean is the following:

Imagine you are programming a game, and just finished implementing that new feature, and now you want to test it to see if it’s working properly. You then run the game and play the new feature. That’s the screen I am talking about: the screen in which you run the game.

I worked on Primateria, the card game that Yfrit Games is currently developing, for six months before having a single screen to run the game. But why?

Why I did this to myself

You see, we don’t have any artists on our team. So when we started making this game, we wanted to construct it in such a way that we would be able to easily change the visuals of the game, without having to do big changes to the code.

The best way we found to do that was separating completely the logical parts (rules, flow of the game, etc) and the visual parts (appearance of cards, animations, player inputs, etc). So how did we do it?

The MVC Architecture

The main thing behind all of this is something called the Model-View-Controller architecture. It’s a pretty common pattern used in Software Engineering, and there are many ways to interpret it. I will now explain how exactly it worked in our project.

Models represent the logical state of the game. Examples of models would be classes like the Card, that contains all the logical properties of a card, like level and elements. Or the GameState, a class that stores the locations of every card in the game (note that I am talking about locations like “deck” or “hand”, not x/y screen coordinates, since that would be a visual thing).

Controllers implement the flow of the game, manipulating the models to do so. An example would be the MechanicExecutor, that implements all the base mechanics of the game. If we take the attack mechanic for example, the MechanicExecutor implements it by manipulating a GameState, changing the locations of cards when the attack occurs.

Views represent the state of the game visually, by accessing/reading the models, but never modifying them directly. They are also responsible for detecting player inputs and communicating them to the controllers. Here would be things like the visuals of a card, with an image and screen x/y coordinates.

The following image shows how the models, views and controllers communicate with each other in the game:

  • The controllers read/access, but also manipulate/modify the state of the models.
  • The models communicate to the views when they are changed.
  • When a change occurs, the views read the state of the models to update the visual state correctly.
  • When a player action is necessary (e.g. choosing a card to attack), the controllers will make a request to the views and wait for a response.
  • When the views receive a request, they will prompt the player and wait for an action, and then respond to the controllers.

Automated Tests

That’s a cute architecture and all, but how did I know that everything worked, without a screen to test the game? Well, the nice part about that architecture is that I could easily replace the Views with something else entirely. That’s where automated tests come into play.

The main idea behind automated tests is writing code to test the code. Here is how my workflow would normally go. I would begin by writing a test to the model (is this case, the GameState):

gameState:moveCard(card, "hand")
local location = gameState:getCardLocation(card)
assert.are_equal(location, "hand")

This test basically:

  • Calls the GameState method that is supposed to move a card to another location;
  • Calls the GameState method to get the location of the card;
  • Checks if the location was updated correctly.

After that, I would implement the method being tested (GameState.moveCard in this case), and then see if the test passed. If it did, then the code was working properly. Otherwise, something was wrong and I had to investigate and fix it.

With the model working properly, the next step would be going one level above, and writing the tests for the controller. Here, I wanted to test the MechanicExecutor.attack method, to check if the targeted card was moved to the damage zone after the attack:

mechanicExecutor:attack(
    {
        attackers = {attacker1, attacker2},
        target = target
    }
)
local location = gameState:getCardLocation(target)
assert.are_equal(location, "damage")

Inside the attack method, I would use the recently implemented GameState.moveCard to move the card to the damage zone:

function MechanicExecutor:attack(params)
    -- ...
    -- other things that happen during an attack
    -- ...
    
    gameState:moveCard(params.target, "damage")
end

The next level is where things get interesting.

The Event System

So far, I only talked about how I implemented the pure logical parts of the game using tests, but there was no need for player input so far. So how would I implement something like the logic of letting the player choose the target for an attack?

As I mentioned in the MVP section, the communication between the Controllers and Views happens through requests. In the context of this game, a request is a way to ask a question, and then wait for a response, without caring who is responding. To implement that, I used Yfrit’ s Event System.

So, let’s go to an example. In the code, the class that would call the MechanicExecutor (the controller we were testing earlier) is called Phases, and it controls everything that happens in each phase of the game. For the battle phase, it would have a method Phases.battle, that would do the following things:

  • Request for a battle phase action, that could be either attacking or ending the phase;
  • If the action was to end the phase, the phase would just end.
  • If the action was an attack, it would also contain extra parameters, which in this case would be a list of attackers and an attack target. The Phases would then perform the attack by calling MechanicExecutor.attack (the controller method we tested earlier).

The implementation of this method looks something like this:

local action, actionParams =
           Event.request("battlePhaseAction")
if action == "endPhase" then
    -- ...
    -- code that ends the battle phase
    -- ...
elseif action == "attack" then
    mechanicExecutor:attack({
        attackers = actionParams.attackers,
        target = actionParams.target
    })
end

In a normal game flow, a View would respond that request, according to what the player clicked on the screen. But in a test environment, we don’t want to depend on the player, so what do we do? Well, that’s simple.

Instead of the player, I would make the test code itself respond to the request, replacing the role of the View. So I wrote a test that looked like this:

Event.listenRequest("battlePhaseAction", function()
    local action = "attack"
    local actionParams = {
        attackers = {attacker1, attacker2},
        target = target
    }
    return action, actionParams
end)

phases:attack()

local location = gameState:getCardLocation(target)
assert.are_equal(location, "damage")

This test basically:

  • Prepares a responder for the battlePhaseAction request, that will respond with an “attack” action, and the proper attackers and target;
  • Calls the Phases.attack method, that internally makes the request, which is automatically responded by the previously prepared responder;
  • Checks if the target card was moved to the damage zone as expected.

And this way, I managed to test the entire attack mechanic. By using that same process, we were able to implement and test all the base mechanics of the game without having to implement a single View/Screen.

Conclusion

So this is how we at Yfrit Games used MVC, automated tests and request communication to separate the logical and visual parts of our game completely.

After all the base mechanics of the game were already working, we finally started actually implementing the first screens. And you know what? It actually worked pretty well. We didn’t need to make any big changes to the previous code, and even managed to keep the logical and visual parts in separated repositories.

So what did you think? Would you use this approach in one of your projects? What would you do differently?

Leave a Reply

Your email address will not be published. Required fields are marked *