Skip to content

Overall Design

Alex Komoroske edited this page Jan 19, 2017 · 50 revisions

Overall Architecture

boardgame is a framework that makes it easy to write boardgames on the web that can be played in realtime, or fall back on notifcations if the other user isn't there.

The server, written in Go, is canonical. It maintains the game state, which is modified by moves that are proposed by users or other events. The client receives updates when a move has been made and updates the UI.

Games and States

A game consists of a particular game among 1 or more real players, and possibly some AI players. A game state represents the entirety of the semantically relevant state of the game. (Different games will be modeled differently, but anything that could be relevant information for other players will need to be captured in the state). A game state can be serialized as a JSON data object, which fully represents the state.

The game state is comprised entirely of 1 or more UserStates. Each UserState represents the semantically complete information representing that user's state. The first user (#0) is a special user called GameMaster, whose state represents key information that does not make sense to encode in any single user's game state. For example, the state of the card draw and discard decks make sense to encode here. Which player ID is the current player is also often encoded here.

The other UserStates are all the same type of object, with the same properties and fields as one another. They are in slots 1 and above.

TODO: is everything a top-level property, or do we need nested states (eek!). If we can't just have top-level properties, things get very hard. Look at the TtR as a survey.

TODO: depending on how everything is modeled, it might be a pain to get state information (i.e. if it's denormalized it might be annoying to 'grab'. Implies a need for a virtual property, which is derived at query time deterministically from states in other parts of the overall state. For example, an isCurrentPlayer property on each PlayerState that checks to see if the currentPlayer property is equal to our id.

#Hidden information

The server can see the full canonical state representing the current game--it must. However, there is often information that a given user may see that others may not. For example, if a user has cards in their hand, that player can see precisely which cards they have, and in which order they are arranged, but other players can only see how many cards that player holds in their hand.

boardgame makes sure that not only is that hidden information never rendered in a visible way to the user, but also the state objects that are loaded clientside and modified never leak that information.

Different UserState properties can be configured with different policies:

  • Public - every user may see this information. For stacks, the precise identity and order of each card is visible to all users.
  • (Stack) Order: The number of items and the ordering of those items is visible, but now what they are. Instead of revealing the actual card, a stable identifier is revealed, so we can keep track of, for example, when the user reorders the (hidden) cards in his hand.
  • (Stack) Count: For a list item, reveal only the number of items in the Stack, not the order of them. This is useful, for example, for a shuffled draw stack, where even the order of the items after they are shuffled should not be visible.
  • Private : no information is revealed. For example, in a game like Secret Hitler, the "IsFascist" property would be set to Private.

boardgame takes care that whenever it generates a JSON representation of the gamestate, it first generates it and then obscures properties based on their policy before transmitting them to a specific client.

#Moves

After a game is initialized, the only way to make modifications to the state is to apply a move. A move is constructed with certain parameters (e.g. User 1 draws the card in slot 5 from the draw area).

TODO: in cases where there are not rounds, but any user may draw a card, the move should not just say which slot to draw from, but "user 1 draws the card from slot 5, which we expect to have value ace of hearts" to make sure that another user doesn't sneak in before them and grab the card they wanted, and they end up drawing the wrong card.

TODO: moves have some properties that have different visibility. For example, a "deal card from deck" has the player the card is going to, but doesn' thave the actual card that is drawn until the card is actually drawn--and then it is only visible to that one user.

A move has a legal method, which returns true if it is legal to apply it at the given game state. For example, a move to draw a card from the draw deck into the current player's hand will only allow the move to apply if the target user is the current user, they are in that phase of their turn, and the draw deck is not empty. This method is important not just for sanity checking, but because a user may have 'proposed' a move when the state was different, and we need to see if it is still legal.

If a move is legal, it is applied. It makes a series of changes to the game state in one transaction, either all or none, and makes sure that state is saved to the database.

Moves should be the smallest unit where we know that all of the actions will definitely apply. For example, if a common action is for a user to draw 3 cards from the top of the deck, if we had a DRAW_3_CARDS action, we could run into a situation where there are only two cards left to draw, at which point the discard must be shuffled into the draw deck, at which point the last card can be drawn. The better way is to represent it as DRAW_CARD which draws one card, and decrements a cardsAllowedToDrawThisTurn on the user object by one at the same time.

Once a move is applied, the version of the gamestate is incremented, and all connected clients are alerted. They will then fetch the information from the moves they haven't yet applied, and advance their local state to represent the server-state. (Bailing out and refreshing the page if sanity checks reveal they got in a state that's inconsistent with the server.)

When a move is proposed (by a user or other mechanism, described below) they are put into a ProposedMove queue. While that queue is not empty, the game engine pulls off the next move in the queue, and sees if it is legal. If it is, it applies it as described above. If it is not, the move is rejected, and the client that proposed it is notified.

TODO: what does 'notifying the client' look like? What if an auto-move is rejected?

Most moves are proposed by users client-side, and then formally proposed to the server. However, given the expansive definition of moves, there are many things that must be changed in response to conditions that have arisen. These are called FixUp moves.

After every Move is applied, the game engine checks the game state to see if any FixUp moves need to be applied. If one (or more) does, then a single FixUp move is proposed by the engine. It skips to the front of the queue and is applied, at which point we check again for if new FixUp moves need to be applied. If not, the next move on the queue (which may have been a user move) is applied.

TODO: what about things that apply after a given timer? For example, after two minutes, the round ends. Likely the way to handle this is to formalize the notion of timers-- a move that starts a timer, which the game engine notes and schedules. Basically a move that is queued to fire at a certain point. If at that time the move is legal, it is applied. Otherwise it is discarded. (What about a weird race where a timer is started to end a mode, and it ends early because all the players are done, but when the timer fires later, everyone's back in the same mode again? That would lead to the mode ending early. Probably best to, like other moves, have a parameter in the move that encodes which instantiation of the round its targeting)

For example, imagine that a user has chosen to draw three cards from the draw deck. Three DRAW_CARD_TO_CURRENT_USER moves are proposed in order. The first two are both legal, but after the second one is applied, the draw deck is now empty. The engine, in its checkFixUp method, detects this, and injects a SHUFFLE_DISCARD_TO_DRAW move. Now the next move runs, and can proceed legally.

Other examples of fixup move include things that notice that the current player has no more legal actions they may do on their turn, and thus we should advance to the next player (and reset whatever state is necessary when they become the player, for example to keep track of remainingCardsAllowedToDraw)

GameState, as discussed above, have to sanitize state for different users depending on the visibility policies of specific users. Moves are applied directly on the server, where the full state is available. However, we also can do much of the move mutation on the client--in those cases we have to reason carefully about how to transform the meaty information about the move in a way that preserves the policies. For example, perhaps a user applied a MOVE to swap the order of the private cards #2 and #3 in his hand. Because the policy for that stack is (Stack) Order, the fact that the user swapped those two cards is visible to other players, but not the contents of the card. If the policy had been (Stack) Count, then the move would have basically looked like a no-op to other players.

TODO: do we need a formalized notion of modes and sub-modes and rounds? Or is that too bespoke to try to formalize?

Components

Every game has a list of components. Components are every item that can be moved or owned in the game: the cards, dice, meeples, resource tokens, etc. Basically, anything that a game's instruction manual would list so that a player could verify they had all of the pieces before the game starts.

TODO: are components/stacks actually a sub-class of a more general items/sets, where item is a pre-enumerated list of things, and set is a stack, but with no notion of physical components with a location?

TODO: is the main board a component, or is it just assumed to exist?

Components can be comprehensively enumerated before the game even begins, often in a way that is always the same across games. (There are odd exceptions, like Cards Against Humanity custom cards.)

Note: the terminology for components is often inspired directly by language applicable to cards, because those exercise all of the interesting properties of components. The terminology can be a bit confusing when applied to non-card components.

Each component is a member of a single Deck. Decks are collections of components that all behave (mostly) the same way. For example, one deck might be a list of all of the Player tokens. Another deck might be all of the cards that could ever be pulled from the main draw pile, whereas another Deck is all of the contract cards (to use an example from Ticket to Ride).

Decks have a stable reference ordering that does not ever change during the life of the game (or often across games). The collection of all decks in a game thus provides an index into all of the components that could be referenced.

Components have static state, and dynamic state. Static state never changes even across different runs of the game--for example, it's the information that is encoded into the printing on the card (e.g. attack value, color, etc). Dynamic state is properties of the component that can change throughout the game. For example, in some games the way you orient the card (horizontal or vertical) is semantically relevant, and thus would be encoded as dynamic state on that card. Dice are another example where the static state encodes the values on the various faces, and the dynamic state would encode which face is currently up.

A Stack is an ordered subset of a Deck. It consists of a reference to the deck it is a subset of, an then an ordered list of integers indexing into that deck. Every deck is split up into 1 or more Stacks, which collectively include each item in the deck, with no card in multiple stacks. This is an important invariant to maintain.

TODO: do stacks have a cap property (how many items they can have? It seems like there are lots of places where we'd have a ton of "stacks" where each has one slot. For example, the five slots of train cards on the board in TtR, or again in TtR whether or not each train slot on the map on routes is occupied, and by which component.

TODO: are they any scenarios I can think of where a card is in multiple stacks?

For example, you might have a deck with a large collection of cards, some of which are not used for this game (perhaps because not enough players are playing). You'd have a number of stacks:

  1. The stack of unused cards. These would never come into play in this card.
  2. The draw stack
  3. The discard stack
  4. The cards held in each player's hand, as a Stack in that UserState
  5. The cards played by a user in front of them, as a Stack.

TODO: think through if games where complex laying out of cards is relevant break this. For example, in innovation with melding etc.

Moves are required to move a card from stack to another, or to move a card in order within a stack. (Although it is possible to have multiple card moves in one logical Move)

Each Stack is owned by precisely one UserState object. Many stacks are owned by GameState (user 0). The UserState object determines the visibility policy for the stack--most of the most interesting policies apply to stacks in particular. That visibility policy applies to both static and dynamic state together.

TODO: is there a case where the static state of a component would be invisible but the dynamic would be visible, or vice versa? Like a game where players change the orientation of cards in their hands to signal a state to other players without revealing which card it is

Client-side architecture

General architecture

The server is always caonical. In the worst case scenario, the client simply asks for a refresh to get back to a known good state.

That said, we also strive to make dynamic changes and animations locally and minimize refreshes.

Views

The client is responsible for taking the GameState (which represents all semantically relevant state) and rendering it in a view. As a general rule, X/Y coordinates are not part of the state--unless their precise position is semantically relevant. (It is far better to have a component be 'owned' by a specific Stack, which then sets its precise X/Y.)

When a new state object is "installed" in the client, each of the stacks that each component is in are responsible for figuring out a position for each component sprite.

The way this is implemented in practice is that the game is a web component, which takes the gameState object and then uses databinding to render out all of the sub-components. (Note that animation makes this complicated, see below)

Applying moves

The server is always canonical with regard to the current state of the game. However, it would be annoying if, as a user, you did a move and then the UI didn't update until after the server applied the move and gave the new state to the client which was then re-rendered anew. (To say nothing of how jarring that would be if the precise X/Y positions of components were not deterministic given which stack and stack position they were in.)

When a user proposes a move, a client-side move object is created. That is then serialized and sent to the server, which then waits for confirmation (actually not really--more on that later). The server receives the move and puts it in the PendingMove queue. At some point (hopefully soon), the move is checked whether it's legal, and then applied. The client is notified that the model has been modified (which would be true no matter who proposed the move), and then we go ahead and apply it locally. (If it was rejected, the server would have notified us some other way how?).

The new entire state object is re-installed locally. Because we know all of the moveable pieces, we can keep track of which one is which (hidden items in stacks get a unique but random ID for us). We then recalculate X/Y positions for each component based on which stack they are in and other logic, and then we use CSS animations to animate those from their previous location to their new location.

Another approach is to actually make modifications to the state object clientside in a way that mirrors what the server did. This is hard for a number of reasons:

  1. We need to possibly re-implement the logic both clientside and serverside (or go through contortions with something like gopherjs).
  2. The server operates on the full state of the objects and only santizes them for a given user as the very last step. But clientside we're dealing with the santized data. Most moves should translate cleanly (e.g. a move component move within a stack that is set to Visibility:Count would just be a no-op), but we'd have to think about the number of actual moves that exist and if there are any that are impossible to do with sanitized data
  3. Even if we can implement the actual model changes with little code, in many cases the IsLegal code is extremely meaty, may rely on state that is sanitized away from this client's visibility, and is necessary to do "optimistic" moves. (One option may be to have the client-side IsLegal be a IsLikelyLegal check, which can be the same as IsLegal, unless it relies on state that may be hidden)

TODO: explore how many atomic move types we need (and if we can group them into a notion of a declarative transaction

TODO: explore if we can formalize IsLegal logic to list the userState objects it depends on in a way that we can bail early with "illegal" for calculations that require state that has been sanitized out of view for this user? And if that's general enough? (For example, is there a game where there's private state in GameState that is necessary for just about every user-initatied move?)

Optimistically applying moves

Even if we know a move is legal, once a user proposes it, there could be a dissatisfying lag from the time that the move is proposed to when the move is confirmed. If nothing happens in that time, the user could feel that the app isn't working.

There isn't a one-size fits all solution. Some examples:

  1. Moves that hugely modify the board state, and where we shouldn't commit it until we're 100% sure it will go
  2. Moves that are almost entirely likely to be accepted, and where if we have to revert it it's not a big deal--like reordering (private) cards in my user hand. These can just be done optimistically and the rendering undone if they didn't go.

A few patterns that can be deployed:

  1. Doing the move optimistically as soon as it is proposed, snapping back if it fails
  2. Doing the move optimistically, but leaving the component as grayed out until it is confirmed
  3. While we're waiting for confirmation, change e.g. the button state to show we got it and are thinking
  4. Modal loading indicator while we wait for a response

Which pattern applies will depend on specific context of that part of the game, realistic latency, etc.

Of course, it's not just how to show the animation, it's how to think about how to modify the user state and then be able to bail out if it's rejected. What if a move that applies before our proposed one is applied messes with state that turns out to be necessary? How do we keep track of position changes that have been proposed but aren't yet locked in? See the scratchpad where we explore these issues

Animations

In many cases, the moves can logically be applied immediately, but we want to animate them over a much longer period of time so the user can tell what is happening.

There are two approaches:

  1. Actually apply the moves quickly, but when playing back moves clientside insert pauses for animations to complete before the next move we know about is performed clientside.

  2. Actually introduce the pause at the server with a configured time after each move to wait before applying the next one.

The first could lead to weird states where one competitor has animations turned up way fast, and in a fast-paced game where everyone may move at the same time, the competitor with normal animations is at a disadvantage.

The second seems odd (it's presntation, not model), introduces weird races on the server, and also forces everything to slow down very far--and if different users have different animation speeds set we still need a way to not have different users way out of sync time wise. For example, if a user draws five cards, each one should be technically separate moves, with a short time delay, but the animations should mostly play concurrently so they appear to fan from the deck to the hand to the user.

TODO: decide which of the two time-based animation approaches to do

Animations often consist of animating a component from one part of the screen to another. This is challenging with databinding, because entirely new instances of the element might be created and destroyed.

There are a couple of obvious approaches:

  1. See if something like React can notice they are the same thing and re-use the same DOM, which would allow it to be animated (how _does) React do animations that require layout to be set before animating?

  2. Use e.g. Polymer databinding, but only to position a transparent, sized shim. Then have all of the components actually exist as top-level sprites, where we set their top/left to their associated shim's position, so we can animate them.

TODO: figure out how to animate elements that move stacks, including looking at Paul Lewis's FLIP pattern closely

TODO: login state, chat, etc

TODO: talk about animations with pregnant pauses TODO: talk about proposed moves that aren't yet confirmed and temporary UI

TODO: do we have move logic locally or just plop in a whole new state object each time? What kinds of moves can we actually do in a move (outside of a legal calculation?) And note that if the legal calculation is most important (and varied), we still need to have that clientside so we can reject the move as soon as it is proposed

#Go implementation patterns

Implementation steps

  1. Implement the very basic interfaces
  2. In the repo in an examples sub-directory, create Tic Tac Toe, using basic Legal() methods.
  3. Test
  4. Implement Pop Four in examples
  5. Create a CLI that makes it possible to propose moves and see state read outs
  6. Implement Blackjack
  7. Implement the logic engine
  8. Create a separate, private repo with examples of other, "harder" games to model.
  9. Keep iterating on APIs and design until all of those are reasonable to model
  10. Create a very basic web viewer with a dumb client side rendering
  11. Work to have move logic application clientside
  12. Just run with it

Clone this wiki locally