- Both players start with the same Tetromino.
- Both players receive the same sequence of Tetrominos, but they will likely get out of sync as one progresses quicker.
- Players see their game in the forefront, but can also spot the other player's game in the background, almost transparent, like a shadow.
- When a player clears one or more lines, the cleared blocks are transferred to the other player (One player's reward is the other's penalty.
The multiplayer game revolves around this core concept. At first it seems like a simple, zero-sum dynamic. But at closer inspection, is bears some complications and isn't necessarily zero-sum. Here are some special cases.
Lines are formed when a player drops their falling Tetromino piece on other still blocks. The block lines that are then transferred to the other player, as a penalty, omit the blocks from the last Tetromino that fell and completed the lines. We want the transferred lines to be incomplete, because otherwise they would be cleared immediately as soon as they appeared in the other player's grid.
It's possible for the blocks received from the other player as a penalty to be a blessing in disguise, and help the receiving player form lines upon the new blocks' arrival. These blocks create the opposite effect for the receiving player, as they offer extra points instead of a penalty. To avoid infinite recursion, these lines are no longer transferred back to the player which created the original lines. This functionality was removed in favor of a simpler, easier to understand game dynamic.
If a player's falling Tetromino is close to hitting the ground, transferred blocks from the other player (which just cleared a few lines) will cause the "ground" to rise a few lines and potentially overlap with the falling Tetromino. In this case the falling Tetromino is pushed up as many lines as needed, and will continue to drop from the new (higher) position afterwards.
This current implementation isn't ideal: If a player grid is almost full, upon receiving blocks from the other player, the overflowing blocks (at the top) will be trimmed.
Actions are synced between users over the network and should follow these rules:
- Actions must be deterministic.
- Actions must be as small as possible.
- Actions must leave as little room as possible for cheating. Eg. An action for moving the active Tetromino to left should describe the user action instead of representing the outcome which is the next active Tetromino position. The latter allows the user to broadcast a new active Tetromino position that couldn't be achieved via regular game actions, and has a larger payload anyway.
- Actions should broadcast the intention and not the outcome. The outcome should be derived locally using the same set of rules that all clients share.
- TODO: Network optimization: Noop actions should be omitted. When hitting a wall.
- TODO: Network optimization: Actions should be batched. Holding a key pressed can yield a dozen actions per second and in these cases it's unrealistic to expect clients to reliably send and receive messages at such high frequency.
These rules tie into each other and overlap, but it's useful to keep each in mind when designing action payloads.
When a user loads a game, the server returns the current state snapshot at the time of the request, already converted into a server-side rendered HTML output. Upon receiving the initial HTML, the client starts fetching JS assets and once they're received it runs the client-side code, which hydrates the static HTML received initially from the server. At this point a socket connection is opened with the server, instructing it to send the client any new action other (active) players dispatch for the loaded game.
From the time the server returns the initial snapshot, to the time the client opens the socket connection, other players might've authored new actions which the connecting user needs to also receive, lest he get the game state out of sync. In other words, once a new user subscribes to receive a game's actions, a backfill operation might be needed.
First off, the server needs to retain actions for a short time window, before discarding them to rely solely on the reduced state snapshot.
TODO: GC strategy for short-term action pool (ie. make it short term)
In order to know that a new action cannot be applied on a state snapshot, there must be a connection between the state snapshot and the last action applied.
To be able to identify a valid chain of actions, actions will also point to the previous action.
While the (async) backfill operation is underway, real time actions can still be received. This means that:
- Real time actions received must be stored in a secondary client state location until backfill has completed.
- Upon backfill completion, we merge the returned actions with other recent actions that might've been received via websocket in the meantime, remove duplicates, and dispatch them into the the local Redux store. From here on new actions are dispatched as they are received. We assume an open websocket connection does not drop messages.
The action id is a local timestamp, separate for each player. Being a timestamp, it allows us to fast forward events while still conveying the rhythm in which the actions were performed.
Caveat: To ensure action order consistency, the action id needs to be unique. So each player will ensure a new action's id is higher than the id of the previous action, in the highly unlikely event that two actions are dispatched in the same millisecond.
Player state is initiated with
lastActionId
equal to0
, andJOIN_GAME
actions also haveprevActionId
set to0
.