Friday, May 27, 2022

Exploring Architectural Concepts Building a Card Game

Key Takeaways

  • A real time card game involving more than one player represents a good playground to exercise some architectural concepts: flexibility in cloud deployment models, reactivity in the front end, testability of complex scenarios.
  • The first concept to test is flexibility when it comes to deploying in the cloud. The best deployment model to adopt depends on the context, so it is important to stay flexible and minimize the effort needed to move from one deployment model to another if circumstances require a change.
  • The second concept is on the Front End. We want to use a reactive model and check up to which point we can move our logic to a pure Javascript / Typescript layer minimizing the dependencies on the specific library / framework we choose to use. 
  • The third concept is about testing, in particular testing interactive multi user scenarios. In these scenarios more than one client is connected to the app and uses it concurrently with other clients, which makes testing more challenging. But, even in such cases, it is possible for developers to run BDD test suites on their laptop, without complex setups and, at the same time, granting a high level of confidence. 
  • In addition to bringing some fun, building a card game app is an opportunity to see these architectural concepts in action with real code and increase the level of confidence on the possibility of applying such concepts to real business scenarios.
     

One of the things I missed during the pandemic were my friends, the possibility to meet them, discuss with them and, why not, play some cards with them.

Zoom could partially work as a substitute for physical presence, but what about cards? What about our games of Scopone*?

So I decided to implement an app to play Scopone with my friends and, at the same time, test “in the code” some architectural concepts which had been intriguing me for some time.

Related Sponsored Content

All the source code of the app can be found in this repo.

What I wanted to find out

Freedom when it comes to deploy a server

An interactive card game app involving more than one player has to have a client part and a server part. The server part has to reside somewhere in the Cloud. But where in the Cloud? As a component running on a dedicated server? As a Docker image on a managed Kubernetes? As a serverless function?

I did not know which was the best option but I wanted to check whether it was possible to maintain the core of the logic of the game independent from the deployment model I would have eventually chosen.

Independence from the framework or library chosen to develop the UI

“Angular is best”. “No, React is much superior and faster”. I read too much of this. But is it really relevant? Shouldn’t we have most of the Front End logic as pure Javascript or Typescript code completely independent from the UI framework or library which we will eventually end up using? I had the feeling this was possible, but wanted to try it for real.

Possibility to test automatically interactive multi-users scenarios

A card game, as other interactive applications nowadays, has multiple users interacting with each other in real time via a central server. For instance, when one plays a card, all others need to see in real time the played card. At the beginning it was not clear to me how to test this type of application. Would it have been possible to test it automatically with simple Javascript testing libraries like Mocha and standard testing practices?

The Scopone app: a good playground to answer my questions

The Scopone app represented a good ground to try to answer in a concrete way the questions I had. So I decided to try to implement it and see which lessons I could draw from it.

The big picture

The rules of the Scopone game

Scopone is a traditional italian card game which is played by 4 players, split in 2 teams of 2, with a 40 cards deck.

At the start of the game all players are given 10 cards each and the first player plays the first card which is put on the table face up. The second player then plays its card. If this card has the same rank as the card on the table, the second player “takes” the card from the table. If no cards are left on the table, the player taking the cards scores a “scopa”. Then the third player plays its card and so on and so forth until all cards have been played.

Enough of rules. The key point to remember here is that when a player plays a card, they change the state of the game, for instance in terms of “which cards are face up on the table” or “which player can play the next card”. 

Structure of the app and tech stack

The Scopone app requires one server instance and four client instances that are launched by the four players from their devices.

If we look at the interactions among the various elements of the game, we note that

  • Players perform actions, for instance a player play a card
  • As a consequence of a player’s action, all players need to be updated with the new state of the game

This means that the clients and the server need a two-way communication protocol, since the clients have to send commands and the server needs to push the updated state. WebSockets is a popular protocol suitable to this aim and available in various languages.

The server is implemented in Go since it has good support for WebSockets and fits well with the different deployment models at hand, in other words it can be deployed as a dedicated server, as Docker image, or as a Lambda. 

The client is a browser-based application implemented in two different flavors: one with Angular and one with React. Both versions use Typescript and leverage a reactive design implemented via RxJs.

The following diagram represents the general architecture of our game app.

Commands and events

In a nutshell, the app works like this:

  • A client sends a command through a message to the server
  • The server updates the state of  the game
  • The server pushes the new state of the game to the clients through a message to the clients
  • When a client receives a message from the server it treats it like an event that triggers the update of the state for that specific client.

This cycle is repeated until the game is over.

Freedom when it comes to deploying the server part of the app

The server receives messages representing commands sent by clients. Based on these commands, it updates the state of the game and sends messages to clients with the new updated state.

A command is a message sent by a client via a websocket channel which is transformed into the invocation of a specific API of the server.

The response produced by the invocation of the API is a picture of the new state which is transformed into a set of messages that have to be sent to each client over the websocket channel.

So, in the server implementation there are two distinct layers with distinct responsibilities: the game logic layer and the websockets mechanics layer.

Game logic layer

This layer is responsible for implementing the game logic, that is to update the state of the game based on the command received and return a picture of the new state to be sent to each client.

This layer therefore can be implemented with an internal state and a set of APIs implementing the command logic. The APIs return the new state that has to be communicated to the clients.

Websocket mechanics layer

This layer is responsible for transforming a message received over the websocket channel into the invocation of the corresponding API with the expected parameters. Additionally,  it transforms the updated state, received as a response from the API invocation, into the set of messages that have to be pushed to the respective client.

Dependency relationships between layers

Based on the previous discussione,  the game logic layer is independent from the concept of websocket. It is just a set of APIs returning a state.

The websockets mechanics layer, on the other hand, is where the websockets specificities are implemented. This layer will depend on the specific deployment model chosen. 

For instance, if we decide to deploy as a dedicated server, we will have to deal with the specific package chosen to implement the websocket protocol (in our case the Gorilla package), while if we decide to deploy as an AWS Lambda function, we have to rely on the Lambda implementation of the websocket protocol.

If we keep the game logic layer strictly separated from the websockets mechanics layer, with the latter importing the former (and not vice versa) we are sure that we can use the game logic layer regardless of the specific deployment model chosen.

Applying this strategy, it was possible to develop a single version of the game logic with the freedom to deploy the server where most convenient.

This brought several advantages. For instance, during the development of the client it is very convenient to run against a local Gorilla websocket implementation, maybe even launched in debug mode from within VSCode. This makes it possible to place breakpoints in the server code and step through the logic triggered by the various commands sent by the clients while playing a real game.

When the time came to deploy the server for production, which was more convenient for real play with my friends, it was possible to deploy the same game logic to the Cloud, for instance to Google Application Engine (GAE).

Furthermore, when I discovered that Google was charging a minimum fee regardless of whether we played or not (GAE always keeps at least one server on), I decided to move the server to AWS Lambda for a full “on demand” model with no change in the game logic code.

Independence from the framework or library chosen to develop the UI

And then came the big question: Angular or React?

But then I also asked myself another question: is it possible to code most of the client logic as pure Typescript, independent of which framework or library will be used to manage the view part of the Front End?

It turned out that it is possible, at least in this case, with some interesting benefits as side effects.

The design of the Front End of the app: view layer and service layer

At the base of the design of the Front End part of the app there are three simple ideas:

  • The client in split into two layers:
    • The view layer, implemented as composable components (yes, both Angular and React share the same basic concept of creating UIs as composition of components) to implement the pure presentation logic.
    • The service layer, implemented in Typescript with no dependency whatsoever on any part of Angular or React, to hold state and implement any logic that manages such state, including the invocation of commands on the remote server and the interpretation of the responses in terms of state changes (in other words a home-made bespoke store).
  • The service layer exposes two types of APIs to the view layer:
    • Public methods that can be called to invoke commands on the remote server or, more generally, to change the state of the client.
    • Public streams of events, implemented as RxJs Observable, which can be subscribed by whichever UI component wants to be notified of state changes.
  • The view layer keeps only two simple responsibilities:
    • Intercept UI events and turn them into invocations of the service layer public API methods.
    • Subscribe to the public API Observables and react to the notifications received with the appropriate changes in the presentation.

An example of View-Service-Server interaction

A player can play a card by clicking on it (suits in the picture are traditional north-eastern italian)

To make it more concrete, let’s consider what it means to play a card.

Let’s assume Player_X is the player that is to play the next card. Player_X clicks on the card “Ace of Hearts” and this UI event triggers the action “Player_X has played Ace of Hearts”.

These are the steps the application goes through:

  1. The view layer intercepts the user generated event and calls the service layer invoking the method playCard passing Ace of Hearts as parameter.
  2. The service layer sends the remote server the message “Player_X has played Ace of Hearts”.
  3. The remote server updates the state of the game and informs all clients of the state changes. For instance it tells all the clients which card Player_X played and who is the next player that can play a card.
  4. The messages with the state updates sent by the remote server are received by the service layer of each client and turned into notifications of specific events over Observable streams made public for clients consumption. For instance, the service layer in the client instance of Player_X will notify false on the stream isMyTurnToPlay$ since Player_X is certainly not the next player. On the other hand, if the other player is Player_Y, the service layer on the client of Player_Y will notify a true on the stream isMyTurnToPlay$.
  5. The view layer of each client has subscribed to the streams of events published by the service layer and will react to the events notified updating the UI as required. For instance, the view layer of Player_Y (the next player) will enable its client to play a card while all other clients serving the other players will disable such a possibility.

View layer and service layer interactions

Light components and heavy services

Following these rules we end up building “light components”, which manage only the UI concerns (presentation and UI event handling) and “heavy services” where all of the logic is kept.

The most important consequence though is that the “heavy services”, which contain most of the logic, are completely independent from the UI framework or library used. There is no dependency on either Angular or React.

More details on the way the UI layer works can be found at the end of the article.

The benefits

Which are the benefits of such an approach?

Certainly not the portability between different frameworks and libraries. Once Angular is chosen it is unlikely that someone wants to switch to React and vice versa. But there are still advantages.

A first advantage of such an approach is that, if implemented thoroughly, it standardizes the way we develop the Front End and makes it easier to reason about it. At the end of the day, it is just another way to design a unidirectional flow of information using a bespoke store (the service layer is just a bespoke store). Being bespoke has the advantage of a lower level of abstraction and greater simplicity at, maybe, the cost of a bit of “reinventing the wheel” feeling.

The biggest benefit though lies in better and easier testability of the application. 

Testing UI is complex, no matter which framework or library you use. 

But if we move most of the code to a pure Typescript implementation, testing becomes easier. We can test the core logic of the application using standard testing frameworks (in our example we use Mocha) and we can also approach complex testing scenarios in a relatively simple way, as we discuss in the next and last section.

Automatically testing interactive real time multi-users scenarios

We have seen that Scopone is a game played by four players.

Four clients have to be connected simultaneously to a central server via WebSockets. Actions performed by one client, for instance “play a card”, trigger updates (side effects) on all clients.

This is an interactive real time multi-user scenario. This means that we need to have multiple clients and a server running at the same time if we want to test the behavior of the app in its entirety.

How can we test such scenarios automatically? Can we test them with standard testing Javascript libraries? Can we test them on a standalone developer workstation? These were the questions to answer next. It turns out that all these things are possible, at least up to a large extent.

What is a test in an interactive real-time multi-user scenario 

Let’s imagine, with a simple example, that we want to test the correct distribution of the cards among all players at the beginning of a game. As soon as a new game is started, all clients receive ten cards each  from the server (the Scopone deck is composed of 40 cards of which each player gets ten).

If we want to test this behavior automatically from a single standalone machine (say, the developer’s machine) we need a local server. This is possible since the server can run locally as a Container or a WebSockets server. So we assume to have a local server up and running on our machine.

But, for the test to run, we need also to find a way to create the right context assumed by the test and launch the action that triggers the side effects we want to test (the distribution of the cards to the players is a side effect of the fact that one player has started the game). In other words we need to find a way to simulate the following:

  • Four players launch the app and join the same game (create the right context)
  • One player starts the game (trigger the side effects we want to test)

Only then can we check whether the server sends the expected cards to all players.

A test in a multi user scenario

How to simulate multiple clients

Each client is composed of a view layer and a service layer.

The APIs of the service layer (methods and Observable streams) are defined in a class (called ScoponeServerService in the implementation code).

Each client creates an instance of this service class and connects it to the server. The view layer interacts with its instance of the service class.

Therefore, if we want to simulate four clients, we have to first create four different instances of the service class and connect all of them to our local server.

Create 4 service class instances representing 4 clients

How to create the context for the test

Now that we have four clients available and connected, we need to build the right context for the test. We need four players and we need that each of them joins the same game. 

Setting the context for the test

Finally, how to test

After we have created the four clients and built the right context, we can run the core of the test. We can have one player sending the command to start the game and then we can check that each player receives the expected amount of cards.

Running the test

Wrapping it up

The test of an interactive multi-user scenario is a function that:

  • Creates one service instance per user
  • Creates the context of the test by sending a sequence of commands to the services in the right order
  • Sends the command that triggers the side effects we want to check (we can say this is the command under test)
  • Verifies that the notifications emitted by the Observable APIs of each service, as consequence of this command (the side effects), contain the data expected

It is BDD run at APIs boundaries of the service layer

We can see this approach as a form of behavior-driven development (BDD) performed against the APIs offered by the service layer

The behavioral specifications are provided, in line with the BDD approach, such as:

  • Given the initial context: 4 players joining a game
  • When: a player starts a game
  • Then: we expect each player to receive 10 cards. 

The function representing the test is written with a sort of DSL which is composed of ad-hoc helper functions whose combination sets up the context (an example of helper function is playersJoinTheGame). 

It is not end-to-end, but it can be very powerful

This is not a full end-to-end test. We are not testing the view layer

But it can still be a very powerful tool, especially if we stick to the rule “Light components and heavy services”.

If the view layer is made up of light components and most of the logic is concentrated in the service layer, then this approach allows us to cover the core of application behavior, both on the client and on the server side, with a relatively simple setup, pretty standard tools (we use Mocha as testing library, definitely not the latest shiniest thing) and on a standalone developer’s machine. 

The net benefit is that developers can create test suites that are fast to run and therefore can be executed often. At the same time, such test suites are really testing the entire application logic, from client to server, giving a high level of confidence even with a multi-user real time application.  

Conclusions

Building a card game app has been an interesting experience. 

Apart from bringing some relief during the darkest days of the pandemic, it gave me the opportunity to explore some architectural concepts with some code.

We often use architectural concepts as abstractions to express our point of views. I find that looking at these concepts in action, even in simple proof-of-concept scenarios, increases our understanding of them and our level of confidence when we eventually use them in a real project.

Appendix: The view layer mechanics

The components of the View layer do two things:

  • Handle UI events and transform them into commands for the service.
  • Subscribe to streams exposed by the service and react to incoming events by updating the UI.

To be more concrete about what the last point means, we can look at one example of logic: how to determine who is the player that can play the next card.

As we said, one rule of the game is that players can play cards one after the other. So, for instance, if Player_X is the first player and Player_Y is the second, after Player_X plays a card only Player_Y can play the next card. All other players can not play any cards. This information is part of the state which is kept by the server.

Any time a card is played the server sends a message to all clients specifying which is the next player.

The service layer turns this message into a notification over an Observable stream called enablePlay$. If the message says that the player can play the next card, the service layer will notify true over enablePlay$ otherwise false.

The component that enables for a player the possibility to play a card has to subscribe to the enablePlay$ stream and react accordingly to the data notified.

In our React implementation this is the functional component Hand. This component defines a state variable, enablePlay, that governs the possibility of playing a card. The Hand component subscribes to the enablePlay$ Observable in an effect hook and, any time it receives a notification from enablePlay$, it sets the value of enablePlay triggering the redraw of the UI.

The relevant code for this particular functionality implemented by the  Hand component using React is the following.

export const Hand: FC = () => {
 const server = useContext(ServerContext);
  . . .
 const [handReactState, setHandReactState] = useState<HandReactState>({
   . . .
   enablePlay: false,
 });
  . . .
 useEffect(() => {
  . . .
  . . .
   const enablePlay$ = server.enablePlay$.pipe(
     tap((enablePlay) => {
       setHandReactState((prevState) => ({ ...prevState, enablePlay }));
     })
   );

   const subscription = merge(
       . . .
     handClosed$
   ).subscribe();

   return () => {
     console.log("Unsubscribe Hand subscription");
     subscription.unsubscribe();
   };
 }, [server]);
  . . .

 return (
   <>
       . . .
       <Cards
           . . .
         enabled={handReactState.enablePlay}
       ></Cards>  
       . . .
   </>
 );
};

The Angular counterpart to this example  is logically identical and is implemented in HandComponent. The only difference is that the subscription to the enablePlay$ Observable is made directly in the template via the async pipe.



from Hacker News https://ift.tt/0QKgJLB

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.