The Game-Master Pattern for UIs

Most web applications are built by mixing behaviour into the view layer. This increases complexity in the view, and makes it hard to debug and test. In this article, I attempt to move all behaviour out of the view. 16 August 2020

# The Game-Master

Consider a turn-based game played between two players: a user and an API. When it is the user's turn, the game-master presents him with a prompt to decide his latest move. When he has made his choice, it becomes the API's turn to play. The user prompt is disabled, replaced with a spinner to indicate that it is no longer his turn. The API makes its move, either resolving or rejecting the user's challenge. Then, a new user prompt is presented, and the game continues, taking turns until the user drops-off from the game, or when he has achieved his conversion goal.

This is often how websites function — as a sequence of turns between user and API. Some turns are possible only after earlier prerequisite have been resolved. Others are always available at any time. More than one turn can be in play at once. Even others can be cancelled before completion.

With this in mind, I propose a game-master centric model to building interactive websites. This model's idle state is when there are no active players. The game-master decides whose turn it is to play, and triggers the relevant prompt: either to the user or to the API. Based on that response, the next turn is triggered. This is in contrast with the user centric model that is much more common-place. In this traditional model, the idle state is waiting for user action and all API interactions are triggered off of the user.

Let's see what the game-master model offers us.

# A tiny state machine

Using only Redux , start with a small state machine with only 4 states to represent user moves, and API moves. Also add its corresponding actions, selectors, and listeners.

For an API request in play, the conventional meanings apply for the states: idle for when there is no current request, active for whilst it is in-flight, success on resolution, and error on rejection.

For a user turn, new but similar meanings apply for the states: idle for when there is no user action needed, active for when there is an active call-to-action, success for when the user completes the action, and error for when there is a user input error, such as form validation errors.

The way it is written below, an arbitrary number of moves may exist. Moves that haven't been made yet default to idle, as are completed moves. This way, the Redux store grows up and down, based on the complexity of the current game.

const initialState = {}
const machine = {
idle: { activate: "active" },
active: { resolve: "success", reject: "error", cancel: "idle" },
success: { cancel: "idle" },
error: { cancel: "idle" }
}

export const reducer = (state = initialState, action) => {
const { name, type } = action
const current = state[name] || "idle"
const next = machine[current] && machine[current][type]

if (!next) { return state }

const { [name]: _, ...remainder } = state
return next === "idle" ? remainder : { ...remainder, [name]: next }
}

const act = (type) => (name, payload = {}) =>
({ name, type, payload })

export const actions = {
activate: act("activate"),
resolve: act("resolve"),
reject: act("reject"),
cancel: act("cancel")
}

const isState = (value) => (name) => ({ plays }) =>
(plays[name] || "idle") === value

export const selectors = {
isIdle: isState("idle"),
isActive: isState("active"),
isSuccess: isState("success"),
isError: isState("error")
}

const onAction = (type) => (name) => (action) =>
action.type === type && action.name === name

export const on = {
activate: onAction("activate"),
resolve: onAction("resolve"),
reject: onAction("reject"),
cancel: onAction("cancel")
}

Now, we have a simple mechanism to action on a particular user or API move. We also have selectors to identify the state of a move, and listeners that we can subscribe to the store for the relevant action invocation. The challenge now, is to wire this up in a way that describes the intended game plays.

# Game Plays

Generators  are a Javascript construct that allows function execution to be paused and resumed. This makes it a suitable candidate to represent plays in our game. What is missing is the game-master to orchestrate the pausing and resuming of these plays. Redux Saga  is one such game-master; its middleware listens to Redux actions and advances the relevant play as necessary.

import { call, cancel, cancelled, fork, put, take } from "redux-saga/effects"
import axios, { CancelToken, isCancel } from "axios"

function * xhr ({ method, url, data }) {
const { token, cancel } = CancelToken.source()
try {
yield call(axios, { method, url, data, cancelToken: token })
} catch (error) {
if (!isCancel(error)) {
throw error
}
} finally {
if (yield cancelled()) {
yield call(cancel)
}
}
}

function * validate (play, config) {
try {
yield put(actions.activate(play))
const result = yield call(xhr, config)
yield put(actions.resolve(play))
return result
} catch (error) {
yield put(actions.reject(play))
} finally {
if (yield cancelled()) {
yield put(actions.cancel(play))
}
}
}

function * theMainPlay() {
let action

yield take(on.activate(plays.prompt))
action = yield take([
on.cancel(plays.prompt),
on.resolve(plays.prompt)
])
if (on.cancel(plays.prompt)(action)) {
return
}

const task = yield fork(validate, plays.validation, {
method: "POST",
url: "/quiz",
data: action.payload
})
action = yield take([
on.cancel(plays.prompt),
on.reject(plays.validation),
on.resolve(plays.validation)
])
if (on.cancel(plays.prompt)(action)) {
yield cancel(task)
return
}

yield take(on.cancel(plays.prompt))
yield put(actions.cancel(plays.validation))
}

The theMainPlay saga is started when the prompt is triggered. It waits for either a cancellation or acceptance from the user. If the user cancels the prompt, that's the end of it. Otherwise, it executes the XHR and waits for either rejection, or resolution of the XHR, or the user cancels it. If the user cancels it, the XHR is cancelled. Otherwise, it shows the XHR result (both resolution and rejection), and waits for the user to dismiss the quiz, and resets everything.

# Business value

The business value of theMainPlay becomes obvious with some Jest  tests to exercise the various code paths. These scenarios describe the conversion funnel from start to end of the play. Omitting some boiler-plate for brevity:

describe("The Main Play", () => {
it("user cancels the prompt", () => {
expect(store).toMatchSelector(selectors.isActive, plays.prompt)
dispatch(actions.cancel, plays.prompt)
})

it("user cancels after XHR request is made", () => {
jest.spyOn(xhr, "xhr").mockImplementation(resolveRequest)

expect(store).toMatchSelector(selectors.isActive, plays.prompt)
dispatch(actions.resolve, plays.prompt, data)
expect(store).toMatchSelector(selectors.isActive, plays.validation)
expect(xhr.xhr).toHaveBeenCalledWith({ method, url, data })
dispatch(actions.cancel, plays.prompt)
})

it("XHR rejects the response", async () => {
jest.spyOn(xhr, "xhr").mockImplementation(rejectRequest)

expect(store).toMatchSelector(selectors.isActive, plays.prompt)
dispatch(actions.resolve, plays.prompt, data)
expect(store).toMatchSelector(selectors.isActive, plays.validation)
expect(xhr.xhr).toHaveBeenCalledWith({ method, url, data })
await expect(store).toWaitSelector(selectors.isError, plays.validation)
dispatch(actions.cancel, plays.prompt)
})

it("user completes the quiz", async () => {
jest.spyOn(xhr, "xhr").mockImplementation(resolveRequest)

expect(store).toMatchSelector(selectors.isActive, plays.prompt)
dispatch(actions.resolve, plays.prompt, data)
expect(store).toMatchSelector(selectors.isActive, plays.validation)
expect(xhr.xhr).toHaveBeenCalledWith({ method, url, data })
await expect(store).toWaitSelector(selectors.isSuccess, plays.validation)
dispatch(actions.cancel, plays.prompt)
})
})

From here, it is a relatively small step to build a UI that is triggered purely by the appropriate selectors.

  • whenever a prompt is active, a component is presented for user action
  • whenever an XHR is active, a spinner is presented for the user to wait
  • whenever an XHR is rejected, the error is shown to the user
  • whenever an XHR is resolved, the result is shown to the user

Further, contained within these views are user elements that dispatch the relevant action to move the play along.

# End to end

Skipping the now trivial view layer, we proceed straight to writing end-to-end tests with Cypress . Take a minute to compare these tests with the ones above; notice the similarity?

describe("The Main Play", () => {
it("user cancels the prompt", () => {
cy.contains("your move") // prompt isActive
cy.contains("cancel").click() // cancel prompt
})

it("user cancels after API request is made", () => {
cy.contains("your move") // prompt isActive
cy.get("input").type(data)
cy.contains("accept").click() // resolve prompt with data
cy.contains("please wait") // validation isActive
cy.contains("cancel").click() // cancel prompt
})

it("XHR rejects the response", () => {
cy.contains("your move") // prompt isActive
cy.get("input").type(data)
cy.contains("accept").click() // resolve prompt with data
cy.contains("please wait") // validation isActive
cy.wait("@xhrReject")
cy.contains("error") // validation isError
cy.contains("reset").click() // cancel prompt
})

it("user completes the play", () => {
cy.contains("your move") // prompt isActive
cy.get("input").type(data)
cy.contains("accept").click() // resolve prompt with data
cy.contains("please wait") // validation isActive
cy.wait("@xhrResolve")
cy.contains("success") // validation isError
cy.contains("reset").click() // cancel prompt
})
})

# Paradigm shifts

Shifting away from a user centric model to a game-master centric model, also shifts some other paradigms.

Routes mark the boundary for some plays. Entering a route will start a play, and exiting it will stop the play. But plays can also span multiple routes. Authentication plays, for example, will span multiple routes.

User analytics are greatly simplified. The funnels in the analytics tools used are already embedded in the plays! See the theMainPlay example, repeated here with additional analytics. See how easy it is?

function * theMainPlay() {
let action

yield take(on.activate(plays.prompt))
yield put(analytics("Started play"))
action = yield take([
on.cancel(plays.prompt),
on.resolve(plays.prompt)
])
if (on.cancel(plays.prompt)(action)) {
yield put(analytics("Cancelled immediately")
return
}

const task = yield fork(validate, plays.validation, {
method: "POST",
url: "/quiz",
data: action.payload
})
action = yield take([
on.cancel(plays.prompt),
on.reject(plays.validation),
on.resolve(plays.validation)
])
if (on.cancel(plays.prompt)(action)) {
yield cancel(task)
yield put(analytics("Cancelled waiting for result"))
return
}

yield take(on.cancel(plays.prompt))
yield put(actions.cancel(plays.validation))
yield put(analytics("Completed play"))
}

Similarly, feature toggles are now dynamic plays; small branches in plays that can be turned on or off. Using the same example, let's say that the ability to cancel an ongoing XHR needs to be feature toggled. This can be achieved as follows.

function * theMainPlay() {
let action

yield take(on.activate(plays.prompt))
action = yield take([
on.cancel(plays.prompt),
on.resolve(plays.prompt)
])
if (on.cancel(plays.prompt)(action)) {
return
}

const task = yield fork(validate, plays.validation, {
method: "POST",
url: "/quiz",
data: action.payload
})
action = yield take([
feature("canCancel") && on.cancel(plays.prompt),
on.reject(plays.validation),
on.resolve(plays.validation)
].filter(Boolean))
if (on.cancel(plays.prompt)(action)) {
yield cancel(task)
return
}

yield take(on.cancel(plays.prompt))
yield put(actions.cancel(plays.validation))
}

# Multiple plays

A more realistic application is not a simple turn-by-turn game between user and API. There are parts where a user or the API needs to perform several turns in series. There are others where there are several in play in parallel. The game-master model allows for this by allowing one play to trigger another, or to fork multiple plays. Indeed we've already seen that the example above showed the user prompt running in parallel with the API validation step.

# Challenges

Like any other model for building async operations, this model is subject to issues during serialisation. This can happen in a server rendered view that is serialised and transferred to a client. Because generators are not serialisable, there is no way to suspend execution on the server, and have it resumed on the client. Similarly, the same problem also occurs when the user simply hits "refresh" on their browser (assuming state is persisted in sessionStorage or localStorage and subsequently restored) — the usual expectation is that the application simply continues as if nothing happened, but generators halfway through their execution cannot be restored.

I stress, this is not unique to the game-master model. Any application using async operations suffer from this too. For example, say a user refreshes his browser part-way during an XHR request — no web technology can put the application back to this awaiting state.

# Summary

The game-master pattern encourages a mental model whereby user and API are peer players. Each is allowed to make its turn according to the game play rules; using Javascript generators. And muliple parallel plays may exist at any one time.

By decoupling behaviour from the UI, the entire model can be built and tested independently of the UI technology. This increases development speed, and arguably even execution speed, as business logic is represented in lightweight native Javascript capabilities, than embedded within UI abstraction layers.