With the latest changes, described here, we have come to a point were game and UI logic communicate with actions and event, using clojure.core.async as an abstraction of the communication channel, the approach can be briefly described as:
For now, all of this is happening inside the same browser but given that we have already in place and abstraction of the communication channel, it should not be hard to put a network between the two layers.
Before moving to that topic, though, few more changes are needed. There is still quite some hardcoded stuff, for example the game state, the user actions, the players data just to name the more relevant ones, so first things to address will be:
:move
actions by looking at the player position in the boardAt this point the base infrastructure will be more or less ready and I think it will be a good time to start adding some tests because, as the game logic will grow, adding more actions and events, it will be easier to catch regressions or to refactor some bits of the project (very likely to happen). The same applies to the schema of actions and events, it would be nice to validate what goes back and forth in the communication channels.
I'll close this update reasoning about a practical approach to create turn based, multiplayer games. If you are curious jump there directly.
Up until now, after a user action the next one was hardcoded, if I want to make a real game then the next possible actions must be calculated based on it is actually possible, for example move to east only if the door to east is open. This is based on where the player is positioned on the board.
(defn valid-move-actions
[target board position]
(->>
(get-in board [:tiles position])
tile/open-walls
(mapv (fn [direction] [:move-player {:direction direction :target target}]))))
In this case we calculate the valid :move
actions based on the open walls of the
tile where the player is in. As we will add more actions we will have to do something
similar. The nice thing is that now it is possible to use both players when developing
the game locally!
Now that it is possible to generate the available actions based on what the player can actually do, it is time to move out the hardcoded events and only use player actions to update the game state, at this stage the actions that we want to (or are able to) support are:
To simplify, the board layout will still be hardcoded, generating a random board will be addressed in a later post.
We will change from a predefined game state and events to a plain state and simulating user actions to create and start a game; it can seem like an unnecessary step but I'd argue that it can help to identify how the players will interact with the game, making the code closer to the final shape.
Given that after initializing the game we already have the actions channel, simulating player actions is as simple as:
(defn simulate-first-user-actions
[actions-chan]
(go
(>! actions-chan [:create-game])
(>! actions-chan [:join-game {:name "one"}])
(<! (timeout 500))
(>! actions-chan [:join-game {:name "two"}])
(<! (timeout 500))
(>! actions-chan [:start-game])))
At this point we have tested the new game layout manually, as we move forward, adding more actions and events it will be harder to keep track of all code paths and use case, so I think it is time to add some tests to help us move forward. People familiar with the TDD would argue that tests would have helped earlier because we can exercise the internal API sooner, discovering weaknesses (in its design or implementation) but at the same time I had no clear idea about how the code would have evolved; what is the value of a well tested bad code design? This is clearly personal taste, so use whatever work for you.
I am keeping most of the code in cljc files, because most of it can be used in
different runtimes, even if at this time I am more interested in the JS side of it.
shadow-cljs has a basic test runner that is more than enough for my needs, I don't
even need to address the browser at this point, at least for the game logic, and I
will leverage this opportunity by having a nodejs
target test alias in the
shadow-clj.edn
project file, that look like this:
:builds {;;; ... other build targets
:tests
{:target :node-test
:output-to "test-out/tests.js"
:autorun true}
}
Now I can run npx shadow-cljs watch game tests
and have the tests to run each time
I save sources and tests, which is quite convenient! Shadow-Cljs has a lot of
configuration options, even for tests, so I'd suggest you to take a look at the
docs to get familiar
with what it offers. It is worth mentioning that in this case I
am targeting nodejs as the runtime and not the browser, this is because the code that
I am testing is not related to the DOM but just the actions to events game loop; when
or if it will come the time to test the UI I'll have to target the browser, which is
something I still have to look at but it is well supported by shadow-cljs, again
refer to the extensive documentation if you want to learn how it works.
Now the project is ready for the next step, that is to make it playable by multiple players.
So far the game logic and UI are running in the same process, and communicating
via core.async/chan
, the following diagram shows how it works.
This is convenient while developing but there is no way to play with other people!
A general approach for multiplayer games is to have a server somewhere which will handle the game logic and updates all clients when something happens. Given the current setup of the code this could be easily achieved by running the actions->events game loop in a web server which would receive actions via WebSocket and broadcast events using the same WebSocket connection; another approach could be to have a endpoint where players can send actions (and possibly receive validation errors) and broadcast events via SSE. My favorite setup would include:
Here is a diagram of how the this client server setup may look like:
Another approach I am considering is inspired by how early multiplayer games worked back in the past century, in the nineties (oh my, how old am I...).
In that setup one instance of the game works both as a client and a server, other players in the same network (or with a direct connection) connect their game client to the remote instance of the game which is responsible of updating the "official" game state and sending updates to the clients. One nice property of this setup is that clients could reduce the latency of the network by pre-computing the next state of the game using the same code which was running at the server side and possibly adjust the state later if it diverged from what the server said it should have been. This method is called client side prediction, I think it has been invented at ID Software.
This worked quite well and did not require a dedicated server to play the game, just the players' PCs and some networking gear. At that time I had few (cheap) network cards that I could lend to friends plus the cables to setup our LAN and play. Today, mostly everyone who can use a computing device can access the internet in a way or another so the network part can be considered as generally available.
Can we replicate this approach with browser based games? After all the game logic can run in the browser and the UI layer can communicate with the game logic via a network abstraction, so it looks like that we can at least dream about not needing a full featured server to run our multiplayer game, all we need is a way to let all the instances of the game to talk! What do have we today (end of 2023) to implement this approach?
First option is WebRTC which gives us the ability to create peer to peer connections between the players. It is available for the browsers in the form of Javascript API and Android and iOS in the form of libraries; the only, minor, downside is that to connect two peers it requires a server side service for peer discovery and NAT traversing. This is an interesting option that I will explore in future.
The second option is to coordinate the communication between the players with a simple web app that can receive actions from a POST endpoint and broadcast events via SSE. The benefit is simplicity (at least if you are already familiar with this tech) and control which will come in handy later, for example to add authentication, persistent storage and so on.
To be honest, how the players will communicate is just an implementation detail and it does not change how the architecture is designed:
The full Excalidraw canvas is available here.
Now that the multiplayer architecture is more or less designed it is time to start thinking about how to implement the thin communication layer. With a network layer it will become more relevant to have a well described actions and events schema, for which I'll use malli. The game logic must also be completed to have a full playable game but this can leverage the local development setup, without the complication of a network; fortunately both aspects can be developed in parallel and are not blocking each other.
I am happy to close this year with all the foundation already laid out, Happy New Year everyone!