[1] And generally dynamic stuff like drag-n-drop, which is infinitely times simpler in any other architecture than in React.
[2] That is also true for business apps, but their animations are usually so simple you can simply use CSS.
If you implement the deterministic update pattern to handle state synchronisation you can add "event" inside the logic that handles updates that pause the processing allowing your animations to be played. In JS, for example:
async function handleUpdate(update) {
if (update.type == "sell-items") {
this.player.inventory[update.itemId] -= 1;
await emitEvent("itemSold");
this.player.money += 10;
await emitEvent("moneyGain");
}
}
Server-side, "emitEvents" would be a no-op. Everything would resolve synchronously.Client-side, the UI can listen to those events to pause the updating of the game state to see the intermediary state of the game and play animations. When the animation is done, it can resolve the promise, resuming the game updating logic.
If an update arrives while an update is being handled, it can be queued so it can be played after the current update finishes.
https://github.com/sjrd/barrage/blob/main/src/main/scala/be/...
Here is a public gist with the `Result` data structure, as well a good portion of the file handling all the game mechanics, which should show it gets used. https://gist.github.com/sjrd/34fe234d1b6232cf42ffda5d23292d3...
Every player action could cause a cascade of updates, which would all be resolved “instantly” to the point no more cascaded updates were left to be processed.
While this is happening, any update that includes an animation pushes that to an “animation stack”, then the animations are played back one by one to show the player what happened. In this animation state most input in disabled and the game is effectively on hold until the animations complete (or are skipped by the player).
The “animations” were basically commands that the Model used to update the View, just with the option to apply them one by one over time. So the model is always up to date as fast as possible as possible, and the view just lags behind a bit and catches up.
Then you can 'execute' the stack within a command VM of some sorts, where instructions can move sprites around, play sounds, etc. You can have 'high level' instructions ("display the enemy's death") implemented as a combination of low level instructions ('reduce that health bar count until it goes to zero' -> 'change entity X's sprite to dead sprite' -> 'give player 200 gold as reward' -> 'play sound' -> 'change text in the text bar to something' -> ...)
It ended up working waaay better than what I was expecting, felt very easy to reason about, wasn't hard to maintain, and scratched that itch of implementing an interesting solution. :)
I ended up using ref's heavily to avoid stale closure and async re-render issues. Which basically amounts to circumventing React and interacting with the DOM directly.
To be more specific, async wasm function was implemented as a poll loop sync function exported to the caller, there was (at the time) no way to move wasm mv memory, so it was persisted while the game was live and replayed from a message log stored in a db after/if preemption.
There’s a card called “Fact or Fiction”. You reveal the top five cards of your deck. Then your opponent splits the cards into two piles. Then you pick one of the two piles to take into your hand.
You’ll need to store structures representing the choices that are intermediary steps (split cards, pick stack) in your state, which is basically function calls and their params (a call stack). This example could get hairier - Magic also features cards with branching logic “choose 1: do a or b”. I can imagine designing cards with large and convoluted possible execution paths.
You could have the card define a schema for state transitions/params and represent all these choices as JSON encoded POJOs, but as a developer it sounds a lot nicer to just be able to suspend an async function every time a choice is made.
You'd represent this kind of choice as a child node. When the user has made their choice, the code can return to the parent node with the choice being made so it can continue with the next "step" of the game.
If you are curious about it, I wrote a cc0 spec which stores hearthstone game state in xml. It’s based on how hearthstone stores game state on the server and client, and it was the first time a replay format was created for hearthstone: https://hearthsim.info/hsreplay/
Incidentally the UI we wrote for hearthstone replays is a react app. It’s funny because looking back it was the first time I used react and typescript, and both were not at all adopted by the js community yet at the time.
Although that strategy enables you to store and recover the state of a game, it doesn't give you the ability to inspect a snapshot of that state. How can you print the card which has just been played, if that data only exists as an argument in the call stack of a suspended async function? In the same way that you can't inspect the local variables captured by a closure, mainstream languages also provide no way to inspect a suspended stack frame.
This problem interferes with debugging, consistency checks (e.g. hashing the game state to check that two clients are in sync), and unit testing.
The Swords and Ravens blog post recommends resolving actions on the client when they don't require secret information, but resolving other actions on the server. You'd also need to resolve actions on the server when they involve RNG.
For actions that require secret information, you would filter the actions sent to the client of any secret information and make sure the code handling the action can handle both the action and the filtered actins.
For actions involving RNG, make all randomness rely on a seed. This seed would be stored server-side and passed along the action when sent to the client. This makes sure the clients can deterministically reproduce the update.
I chose to go with a monadic approach in boardgamelab.app (I'm not limited by language features since I'm designing it from the ground up for this use case). The language is pure and functional with managed effects. You can express things like:
set of cards -> filter by action cards -> choose -> [card]
do stuff with [card]
written in a synchronous style, while under the hood it:1. suspends the rule.
2. waits for the user to make a choice.
3. resumes the rule with the choice made by the user.
(note: listed above is a simplified text representation. The Boardgame Lab structure editor uses a block-based visual language.)
If written in Haskell, the underlying monad would look something like this:
{-# LANGUAGE ExistentialQuantification #-}
newtype Rule a = Rule {fn :: State -> (RuleResult a, State)}
data RuleResult a
= Done a
| forall x. Show x => Choose [x] (x -> Rule a)
instance Monad Rule where
m >>= k = Rule $ \state ->
case fn m state of
(Done a, state') -> fn (k a) state'
(Choose list m', state') -> (Choose list (m' >=> k), state')
i.e. each rule returns either a completed result or a choice along with a continuation of the remainder of the rule past that choice.
This design makes it easy to implement optimistic updates, rollback, replays, automated testing, and recovery after a disconnection. It's a surprisingly good fit for UI, too; you can render simple games as a React component which takes the current State as one of its props.
However, a stream of context-free actions can be a really inconvenient representation for some games. The rules of a board game are often like the control flow of a computer program: you'll see branching, iteration, local variables, function calls, structured concurrency, and sometimes even race conditions and reentrancy. When you try to represent all of this logic as a State object, you're basically maintaining a snapshot of a "call stack" as plain data, and manually resuming that "program" whenever you handle an action. It doesn't seem ideal.
I've been sketching a board game engine which would represent the game logic as normal code instead. It seems promising, but it really needs a couple of language features which don't exist in the mainstream yet, like serialisation of suspended async functions.