Cardlings Development Insights (Part 1)
Posted on June 4th, 2019 by Gio
Some insights into the development of our HTML5 game Cardlings. What went right and what went wrong, part 1.
READ MORE

Today we are releasing our indie game Cardlings on Steam. It's a tactical strategy game that introduces new gameplay mechanics to bring something new to the turn-based genre. Think Final Fantasy Tactics with a twist, with no waiting time between turns.

Working on the game has been a great learning experience, and an useful exercise that's helped us identify what features of the WADE engine needed more attention to support the development of a reasonably complex commercial game.

Now I'd like to share some of the things I've learned from it, by writing a series of blog posts. In each post I'll describe in some detail one thing that we got right and one thing that we got wrong. So let's get started.

What went right: client-server architecture

JavaScript is an awesomely flexible language that allows you to write code that can run in different contexts. Unlike many other solution, if you're making a multiplayer game with JavaScript, you can probably reuse much of the client code on your server. And you should absolutely do that.

I think with Cardlings we absolutely nailed this aspect. Essentially we developed a single player game, and then we were able to turn it into a multiplayer game in just a few days, reusing the vast majority of the client code on a node.js server.

So here's a short overview of how we structured it. In a single player game everything (obviously) runs in your browser, or inside Electron / NW.js; we have a Server class, and create a globally accessible instance of it. In line with the naming convention used by many WADE projects, we do something like

wade.app.server = new Server(config);

Where config is just some JSON that describes things such as the map size, the maximum time for each turn, etc.

The basic idea is that the client then "connects" to this local server class and exchanges messages with it. So we have a globally accessible function that looks like this:

wade.app.messageToServer = function(data)
{
	...
};

Without going into the details of the implementation, this function essentially is a way of calling member functions of wade.app.server. So if the client says

wade.app.messageToServer({message: doSomething})

Then this function, internally, will call

wade.app.server.doSomething();

Conversely, we also have a wade.app.messageFromServer function to act on the messages that the Server class sends to the client. Pretty straightforward!

In a single player game, we also have an AI player. I'll write a separate blog post regarding the AI architecture, but for now it should suffice to say that the AI player is a little script that runs in its own thread (i.e. in a standalone WebWorker).

The AI Player uses a similar function to "connect" and exchange messages with the server class. The implementation of this function is of course different (it uses the WebWorker messaging system to exchange messages), but the way it is used is the same. Basically when the AI wants to do something, it'll call its own version of

messageToServer({message: doSomething});

which ends up executing wade.app.server.doSomething();

Now the really, really nice part of it, is that when you want to do this with a remote server, you don't need to change much of your code.

A remote server is essentially a node.js application, where we also do something like

server = new Server(config);

Note that Server is the exact same Server class that we used in single player games.

The only thing that changes is the mechanism that we use to exchange messages with the server. In other words, we just need a different implementation of wade.app.messageToServer and wade.app.messageFromServer, using WebSockets or your preferred remote messaging mechanism. But everything else is exactly the same.

Sure there is a (very tiny) wrapper on the server side to validate incoming messages (to prevent malicious users executing arbitrary functions on a remote server). But other than that, the server code is the same as the client code. We had to add less than 100 lines of JavaScript to it.

Overall I think this worked really well. Imagine what would have happened if we had done this with something like C# on the client and PHP on the server... the amount of duplicate code functionality that would have to be re-written in different languages (our game server is about 20k lines of JS), and tested separately for single and multiplayer games. Oh the horror! I'm so glad we went with JavaScript!

What went wrong: building on top of prototype code

In an ideal world, you think really hard about the game that you want to make. You then write design documents that describe all the details of your gameplay. You then write more documents that describe your software architecture, analysing different solutions with their pro's and con's and finally decide on the best one.

Now I've been making games (AAA and indie, PC and console, mobile, etc...) for over two decades. Let me tell you that whether you're a big AAA studio with thousand of people or a solo dev, this process described above never ever works.

What happens in reality is that you have an initial idea, then as you start implementing it you realize that it's somewhat flawed, and you make adjustments to it. Then further down the line you think "that's not quite right, let's make more adjustments", and then more... until you have a fundamentally different concept than what you started with (in terms of gameplay, but also software architecture).

At this point you have to be brave. We were not, but it was a mistake. You should be. You should take all the code that you've written up to this point and throw it away. When you've prototyped your concepts enough, and you're happy that you've found the right solution (both in terms of gameplay and code architecture), start re-implementing everything from scratch.

This seemed counter intuitive at the time. We thought: "surely this code is not ideal, but most of it is usable. Let's fix it here and there rather than rewriting it... it'll work, it won't be optimal but we won't waste a couple of months redoing something that mostly works".

In hindsight, that was a very bad decision. We ended up wasting way more than a couple of months trying to wrestle with an architecture that wasn't quite right for the type of game that we were making.

Just as an example (but I could make many such examples): this type of game absolutely needs to have a clearly defined game state object (a pure data structure), and the client should just be a visual representation of that game state.

But when we started prototyping things, we obviously wanted to see some results quickly on our screens. So we went with the lazy approach of having a client application with game objects (the units moving on the board) that contained a mixture of state information (where the unit is on the board, can it move this turn, etc) and functions to move these object around, play animations, etc.

Retrospectively, we realized that if we did things that way, synchronizing everything across different entities (the local client, the server, the ai player) would become a nightmare. Saving and loading games would also become a nightmare. We should have been brave and, rather than patching things so we could keep building on top of our prototype code, we should have thrown everything away and started from scratch.

Well, now we know. We'll be braver next time.

Post a Comment
Add Attachment
Submit Comment
Please Login to Post a Comment