Depict-It is a party game for 4 to 8 players (ideally!) where you mutate a phrase through drawings and captions, to make up funny scenarios with your friends.
You can play Depict-It online at this url: https://depictit.ably.dev
- The game is played in rounds.
- Each player is provided with a
Game Stack
containing aCaption
and a blank screen for them to draw on. - They have 180 seconds to draw a picture of what is described in the caption.
- Once either all players have finished, or 180 seconds elapse, each drawing is passed to the next player.
- Now each player writes a caption which describes the drawing presented to them.
- Once the first player has their own
Game Stack
returned to them theScoring phase
begins. - During scoring, each progression from starting caption through drawings and descriptions is displayed. The players can vote on the funniest card in the progression.
- Points are awarded to each player based on the number of votes they've received and the
Host
can start a new round.
If you're just interested in running this project on your own machine, or on Azure, scroll to the bottom of this document for instructions. The rest of this readme is a teardown, and an explaination of how the game is made.
- What are we going to build?
- Dependencies
- Designing the game
- The game UI
- Recap
- Running on your machine
- Hosting on Azure
- Helpful Resources
Depict-It
is a progressive web app. It is built with JavaScript, Vue.js, HTML and CSS.
The game uses the Ably Basic Peer to Peer demo as a base and Ably Channels to send messages between players.
We'll be hosting the application on Azure Static Web Applications and we'll use Azure Blob Storage to store user generated content.
The app uses Vue.js and Ably.
Vue (pronounced /vjuː/, like view) is a progressive framework for building user interfaces. It is designed from the ground up to be incrementally adoptable, and can easily scale between a library and a framework depending on different use cases. It consists of an approachable core library that focuses on the view layer only, and an ecosystem of supporting libraries that helps you tackle complexity in large Single-Page Applications. - vue.js Github repo
Vue.js is a single-page app framework, and we will use it to build the UI of the app. The Vue code lives in index.js and handles all of the user interactions. We're using Vue because it doesn't require a toolchain and it provides simple binding syntax for updating the UI when data changes.
A Vue app looks a little like this abridged sample:
var app = new Vue({
el: '#app',
data: {
greeting: "hello world",
displayGreeting: true,
}
methods: {
doSomething: async function(evt) { ... }
}
});
It finds an element with the id of app
in the markup, and treats any elements within it as markup that can contain Vue Directives
- extra attributes to bind data and manipulate the HTML based on the application's state.
Typically, the Vue app makes the properties of the data object available to bind into your markup (such as greeting
in the above code snippet). When data changes, it'll re-render the parts of the UI that are bound to it.
Vue.js exposes a methods
property, which can be used to implement things like click handlers and callbacks from the UI, like the doSomething
function above.
This snippet of HTML should help illustrate how Vue if-statements and directives work:
<main id="app">
<div v-if="displayGreeting" v-on:click="doSomething">
{{ greeting }}
</div>
</main>
Here you'll see Vue's v-if
directive, which means that the div
and its contents will only display if the displayGreeting
data property is true.
You can also see Vue's binding syntax, where we use {{ greeting }}
to bind data to the UI.
The app uses Ably for pub/sub messaging between the players. Ably is an enterprise-ready pub/sub messaging platform that makes it easy to design, ship, and scale critical realtime functionality directly to your end-users.
Ably Channels are multicast (many publishers can publish to many subscribers) and we can use them to build peer-to-peer apps.
"Peer to peer" (p2p) is a term from distributed computing that describes any system where many participants, often referred to as "nodes", can participate in some form of collective communication. The idea of peer to peer was popularised in early file sharing networks, where users could connect to each other to exchange files, and search across all of the connected users. In this demo, we're going to build a simple app that will allow one of the peers to elect themselves to be the "leader", and co-ordinate communication between each instance of our app.
In order to run this app, you will need an Ably API key. If you are not already signed up, you can sign up now for a free Ably account. Once you have an Ably account:
- Log into your app dashboard.
- Under “Your apps”, click on “Manage app” for any app you wish to use for this tutorial, or create a new one with the “Create New App” button.
- Click on the “API Keys” tab.
- Copy the secret “API Key” value from your Root key, we will use this later when we build our app.
This app is going to use Ably Channels and Token Authentication.
In PubSubClient.js we make a class called PubSubClient
- which adds metadata to messages sent outwards, so we don't have to remember to do it in the calling code.
class PubSubClient {
constructor(onMessageReceivedCallback) {
this.connected = false;
this.onMessageReceivedCallback = onMessageReceivedCallback;
}
First we define a constructor
for the class - and set up some values - a property called connected
, set to false, and onMessageReceivedCallback
- a function passed to the constructor that we will use later when Ably messages arrive.
Inside the PubSubClient
class, we define a connect
function:
async connect(identity, uniqueId) {
if(this.connected) return;
this.metadata = { uniqueId: uniqueId, ...identity };
const ably = new Ably.Realtime.Promise({ authUrl: '/api/createTokenRequest' });
this.channel = await ably.channels.get(`p2p-sample-${uniqueId}`);
this.channel.subscribe((message) => {
this.onMessageReceivedCallback(message.data, this.metadata);
});
this.connected = true;
}
While we're making a connection, we're subscribing to an Ably Channel and adding a callback function that passes on the data
property from the Ably message. The data property in the Ably message is the JSON that the peers
sent, along with some identifying metadata
. The PubSubClient
calls the callback function that we pass to its constructor with the data and the metadata we receive from Ably - in this case, the metadata would contain the identity
object with a unique ID and name for each player.
In the PubSubClient
we also define a sendMessage
function, that adds some functionality on top of the default Ably publish
.
sendMessage(message, targetClientId) {
if (!this.connected) {
throw "Client is not connected";
}
message.metadata = this.metadata;
message.forClientId = targetClientId ? targetClientId : null;
this.channel.publish({ name: "myMessageName", data: message});
}
}
This ensures that whenever sendMessage
is called, the data stored in this.metadata
that was set during construction, is included. We're also making sure that if the message is for a specific peer - set using targetClientId
- then this property is added to our message before we publish it on the Ably Channel.
The PubSubClient
is passed to the instances of our P2PClient
and P2PServer
classes, to make sure they publish messages in a predictable way.
The application is composed of a Vue
UI, and two main classes, P2PClient
and P2PServer
.
The peer
who elects themselves as host will be the only one to have an instance of P2PServer
and all of the peers
will be P2PClients
. When we define the Vue app, we create two null
properties, one for each of these things, inside Vue data
:
var app = new Vue({
el: '#app',
data: {
p2pClient: null,
p2pServer: null,
...
When a Vue instance is created, it adds all the properties found in its data object to Vue’s reactivity system. When the values of those properties change, the view will “react”, updating to match the new values.
By defining both of the p2pClient
and p2pServer
properties inside of Vue's data object, they become reactive - any changes observed to the properties will cause the UI to re-render.
Our Vue app only contains two functions, one to start hosting
and the other to join
. In reality, they're both doing the same thing (connecting to an Ably channel
by name), but depending on which button is clicked in the UI, that peer
will either behave as a host or a client.
host: async function(evt) {
evt.preventDefault();
const pubSubClient = new PubSubClient((message, metadata) => {
handleMessagefromAbly(message, metadata, this.p2pClient, this.p2pServer);
});
const identity = new Identity(this.friendlyName);
this.p2pServer = new P2PServer(identity, this.uniqueId, pubSubClient);
this.p2pClient = new P2PClient(identity, this.uniqueId, pubSubClient);
await this.p2pServer.connect();
await this.p2pClient.connect();
},
The host
function creates an instance of the PubSubClient
and provides it with a callback to handleMessageFromAbly
. Afterwards, it:
- Creates a new
Identity
instance, using thefriendlyName
bound to our UI. - Creates a new
P2PServer
. - Creates a new
P2PClient
. - Connects to each of them (which in turn, calls
connect
on thePubSubClient
instance).
Joining is very similar:
join: async function(evt) {
evt.preventDefault();
const pubSubClient = new PubSubClient((message, metadata) => {
handleMessagefromAbly(message, metadata, this.p2pClient, this.p2pServer);
});
const identity = new Identity(this.friendlyName);
this.p2pClient = new P2PClient(identity, this.uniqueId, pubSubClient);
await this.p2pClient.connect();
}
Here, we're doing exactly the same as the host, except we're only creating a P2PClient
.
handleMessageFromAbly
is the callback function that the PubSubClient
will trigger whenever a message is received on the Ably Channel.
function shouldHandleMessage(message, metadata) {
return message.forClientId == null || !message.forClientId || (message.forClientId && message.forClientId === metadata.clientId);
}
function handleMessagefromAbly(message, metadata, p2pClient, p2pServer) {
if (shouldHandleMessage(message, metadata)) {
p2pServer?.onReceiveMessage(message);
p2pClient?.onReceiveMessage(message);
}
}
handleMessageFromAbly
is responsible for calling onReceiveMessage
on the instance of P2PServer
if the current player is the host
, and then calling onReceivedMessage
on the instance of P2PClient
.
If the received message has a property called forClientId
and it is not for the current client, the message will not be processed.
This is deliberately not secure. All the messages sent on our Ably channel
are multicast, and received by all peers, so it should not be considered tamper proof - but it does prevent us from having to filter inside of our client and server instances.
The P2PClient
class does most of the work in the app. It is responsible for sending a connected
message over the PubSubClient
when connect
is called, and most importantly, for keeping track of a copy of the serverState
whenever a message is received.
class P2PClient {
constructor(identity, uniqueId, ably) {
this.identity = identity;
this.uniqueId = uniqueId;
this.ably = ably;
this.depictIt = null;
this.serverState = null;
this.countdownTimer = null;
this.state = {
status: "disconnected",
instructionHistory: [],
lastInstruction: null
};
}
The P2PClient
constructor assigns its parameters to instance variables, and initializes a null
this.serverState
property, along with its own client state in this.state
.
We then go on to define the connect
function:
async connect() {
await this.ably.connect(this.identity, this.uniqueId);
this.ably.sendMessage({ kind: "connected" });
this.state.status = "awaiting-acknowledgement";
// this.depictIt = new DepictItClient(this.uniqueId, this.ably);
}
This uses the provided PubSubClient
(here stored as the property this.ably
) to send a connected
message. The PubSubClient
is doing the rest of the work - adding in the identity
of the sender during the sendMessage
call. It also sets this.state.status
to awaiting-acknowledgement
- the default state for all of the client instances until the P2PServer
has sent them a connection-acknowledged
message.
OnReceiveMessage
does a little more work:
onReceiveMessage(message) {
if (message.serverState) {
this.serverState = message.serverState;
}
switch (message.kind) {
case "connection-acknowledged":
this.state.status = "acknowledged";
break;
/*case "instruction":
this.state.instructionHistory.push(message);
this.state.lastInstruction = message;
break;*/
default: { };
}
}
There are two things to pay close attention to here - firstly that we update the property this.serverState
whenever an incoming message has a property called serverState
on it. Clients use this to keep a local copy of whatever the host
says its state is, and we'll use this to bind to our UI later. Secondly, there is a switch on message.kind
- the type of message we're receiving. In this case, we only actually care about the connection-acknowledged
message, and updating the this.state.status
property to acknowledged
once we receive one.
There are a few commented lines in this code that we'll discuss later on.
The P2PServer
class hardly differs from the client. It contains a constructor that creates an empty this.state
object:
export class P2PServer {
constructor(identity, uniqueId, ably) {
this.identity = identity;
this.uniqueId = uniqueId;
this.ably = ably;
// this.stateMachine = DepictIt({ channel: ably });
this.state = {
players: [],
hostIdentity: this.identity,
started: false
};
}
It also contains a connect function that connects to Ably via the PubSubClient
:
async connect() {
await this.ably.connect(this.identity, this.uniqueId);
}
And finally, it contains an onReceiveMessage
callback function that responds to the connected
message:
onReceiveMessage(message) {
switch (message.kind) {
case "connected": this.onClientConnected(message); break;
default: {
// this.stateMachine.handleInput(message);
};
}
}
All of the work is done in onClientConnected
:
onClientConnected(message) {
this.state.players.push(message.metadata);
this.ably.sendMessage({ kind: "connection-acknowledged", serverState: this.state }, message.metadata.clientId);
this.ably.sendMessage({ kind: "game-state", serverState: this.state });
}
When a client connects, we keep track of their metadata
and then send two messages. The first message is a connection-acknowledged
message - that is sent specifically to the clientId
that just connected. The second is a game-state
message, with a copy of the latest this.state
object, that will in turn trigger all the clients to update their internal state.
There's a little more that happens in the server class (in the currently commented stateMachine
line), but let's talk about how our game logic works first. We'll revisit expanded versions of P2PClient
and P2PServer
later in this article.
The game plays out over messages between the host
and all of the players
.
We send messages from the host
to each individual client representing the next thing they have to do. The game stacks
(the piles of Depict-It cards), are stored in memory in the host's browser, with only the information required to display to each respective player sent in messages at any one time. This keeps our message payloads small and means we can structure the application in pairs of messages - requests for user input and their responses.
The game has five key phases:
- Dealing and setup
- Collecting image input from players (repeats until game end)
- Collecting text captions from players (repeats until game end)
- Collecting scores from players
- Displaying scores
Each of these phases is driven by pairs of messages.
We store a variable called lastMessage
inside the P2P client
. This allows us to make the UI respond to the contents of this last message. This is a simple way to control what is shown on each player's screen.
We'll use a message type called wait
to place players in a holding page while other players complete their inputs.
Here are the messages used in each phase of the game:
Phase | Message kind | Example |
---|---|---|
Dealing and setup | No messages | |
Collecting image input | drawing-request |
{ kind: "instruction", type: "drawing-request", value: lastItem.value, timeout: 30_000 } |
Collecting image input response | drawing-response |
{ kind: "drawing-response", imageUrl: "http://some/url" } |
Collecting caption input | caption-request |
{ kind: "instruction", type: "caption-request", value: lastItem.value, timeout: 30_000 } |
Collecting caption input response | caption-response |
{ kind: "caption-response", caption: "a funny caption" } |
Collecting scores from players input | pick-one-request |
{ kind: "instruction", type: "pick-one-request", stack: stack } |
Collecting scores from players response | pick-one-response |
{ kind: "pick-one-response", id: "stack-item-id" } |
skip-scoring-forwards |
{ kind: "skip-scoring-forwards" } | |
Displaying scores | show-scores |
{ kind: "instruction", type: "show-scores", playerScores: state.players } |
wait |
{ kind: "instruction", type: "wait" } |
Each of these messages is sent through the PubSubClient
class, adds some identifying information (the id of the player that sent each message) into the message body for us to filter by in the code.
As our game runs, and sends these messages to each individual client, it can collect their responses and move the game state
forwards.
Luckily, there isn't very much logic in the game, it only has to:
- Ensure that when a player sends a response to a request, it is placed on the correct
game stack
of items. - Keep track of scores when players vote on items.
- Keep track of which stack each player is currently holding.
We need to write some code for each of the game phases to send these p2p messages
at the right time, and then, build a web UI that responds to the last message received to add a gameplay experience.
We're going to use a software pattern called a State Machine
- a way to model a system that can exist in one of several known states, to run the game logic.
Next we'll write code to capture the logic of the game. We're going to break the phases of the game up into different Handlers
- that represent both the logic of that portion of the game, and the logic that handles user input during that specific game phase.
Our implementation is part state machine, part command pattern handler.
Let's take a look at what state machine code can look like - here's a two-step game definition, taken from one of our unit tests:
const twoStepGame = () => ({
steps: {
"StartHandler": {
execute: async function (state) {
state.executeCalled = true;
return { transitionTo: "EndHandler" };
}
},
"EndHandler": {
execute: async function (state) { }
}
}
});
This game definition doesn't do anything on its own - it's a collection of steps
. This example shows a start handler that just flags that execute has been called, and then transitionTo
s the EndHandler
.
A game definition looks like this:
const gameDef = () => ({
steps: {
"StartHandler": { ... },
"EndHandler": { ... }
},
context: {
some: "object"
}
});
- Steps must be named.
- Steps must contain
StartHandler
andEndHandler
. - Properties assigned to the
state
object duringhandleInput
can be read in theexecute
function. context
can be provided, and can contain anything you like to make your game work.
Here's one of the handlers from the previous example:
{
execute: async function (state, context) {
await waitUntil(() => state.gotInput == true, 5_000);
return { transitionTo: "EndHandler" };
},
handleInput: async function(state, context, input) {
state.gotInput = true;
}
}
This is an exhaustive example, with both an execute
and a handleInput
function, though only execute
is required.
- Handlers must contain an
execute
function. - Handlers can contain a
handleInput
function. - Handlers can call
waitUntil(() => some-condition-here);
to pause execution while waiting for input. handleInput
can be called multiple times.waitUntil
can be given atimeout
inmilliseconds
.context
will be passed to theexecute
andhandleInput
functions every time they are called by theGameStateMachine
.- Handlers must return a
transitionTo
response from theirexecute
function, that refers to the nextHandler
. - Handlers must be
async functions
.
The GameStateMachine
takes a Game Definition
- comprised of steps
and an optional context
object, and manages which steps are executed and when. It always expects a game to have a StartHandler
and an EndHandler
- as it uses those strings to know which game steps to start and end on.
Create a new instance of a game by doing something like this:
const game = new GameStateMachine({
steps: {
"StartHandler": { ... },
"EndHandler": { ... }
},
context: {
some: "object"
}
});
Then, when you have a game
object, you can call game.run();
to start processing the game logic at the StartHandler
.
The constructor for the GameStateMachine
takes the steps
and the context
and saves them inside itself.
Once that's done, the run
function does all the hard work.
async run() {
console.log("Invoking run()", this.currentStepKey);
this.trackMilliseconds();
const currentStep = this.currentStep();
const response = await currentStep.execute(this.state, this.context);
if (this.currentStepKey == "EndHandler" && (response == null || response.complete)) {
return; // State machine exit signal
}
if (response == null) {
throw "You must return a response from your execute functions so we know where to redirect to.";
}
this.currentStepKey = response.transitionTo;
this.run();
}
The state machine:
- Keeps track of the
currentStepKey
- this is the string that you use to define yoursteps
in thegame definition
. - Keeps track of time.
- Awaits the
execute
function of theStartHandler
. - Evaluates the response.
Once a response from the current handler has been received:
- If the
currentStepKey
isEndHandler
thenreturn
- the game has concluded. - Otherwise, update the
currentStepKey
to be the target of thetransitionTo
response - changing the current active state of the game. - Call
run
again, to process the step we've just arrived at.
This flow of moving between game steps based on the outcome of the current step allows us to define all kinds of games!
The state machine contains a handleInput
function:
async handleInput(input) {
const currentStep = this.currentStep();
if (currentStep.handleInput) {
currentStep.handleInput(this.state, this.context, input);
} else {
console.log("Input received while no handler was available.");
}
}
We pass user input to this function and it will find the currently active step, and forward the input onto the relevant handleInput
function defined in it. This means that if any of our steps require user input, the input will be passed through this function.
We can connect this up to our Web UI and Ably connection later.
Inside /app/js/game/ there are a series of files. The ones with DepictIt
in the filename contain the game logic.
DepictIt.js
DepictIt.cards.js
DepictIt.handlers.js
DepictIt.types.js
GameStateMachine.js
DepictIt.js
is the entrypoint, and references all of the game handlers, returning the Game Definition
needed to create a game:
export const DepictIt = (handlerContext) => new GameStateMachine({
steps: {
"StartHandler": new StartHandler(),
"DealHandler": new DealHandler(),
"GetUserDrawingHandler": new GetUserDrawingHandler(180_000),
"GetUserCaptionHandler": new GetUserCaptionHandler(60_000),
"PassStacksAroundHandler": new PassStacksAroundHandler(),
"GetUserScoresHandler": new GetUserScoresHandler(),
"EndHandler": new EndHandler()
},
context: handlerContext
});
DepictIt
is a function because we're going to pass in an Ably connection inside the handlerContext
parameter, but it returns a fully created GameStateMachine
instance to run in the Vue.js app. The game is defined as a series of handlers in the sample above. Each of these game handlers are imported from the DepictIt.handlers.js file.
Each Handler
has access to an ably client
supplied as a property called channel
in a context
object. The game works by having the hosting player's browser keep track of where all the game hands
are, sending players p2p messages to make the client code in their browsers prompt the players for input.
Each of these messages looks similar:
context.channel.sendMessage({
kind: "instruction",
type: "drawing-request",
value: lastItem.value,
timeout: this.waitForUsersFor
}, player.clientId);
They each contain a property called kind
with a value of instruction
, which allows the clients to process these messages differently to the standard connection
messages. They also each have a type
- which varies depending on which phase of the game is currently being played.
Handlers
control which message types
the players send. Additionally, messages will always contain a value
.
This value
, when in the drawing phase of the game, is going to be the prompt
the player is using to draw from. If we're in the captioning
phase of the game, it'll contain the URL of the image they need to caption so our player's browser can render it in the UI.
Messages can also feature an optional timeout
value (some of the steps have a limit on the length of time they'll wait for users to reply with a drawing or caption), so including this timeout
in the instruction
means we can render a timer bar on the client side.
Let's now dive into a few of our steps and take a look at what they do.
On execute
:
- Creates prompt deck imported from DepictIt.cards.js.
- Shuffles deck.
- Transitions to
DealHandler
.
On handleInput
:
- There is no user input.
On execute
:
- Creates
Game Stack
for every player instate.players
. - Adds prompt to the top of the
Game Stack
. - Transitions to
GetUserDrawingHandler
.
On handleInput
:
- There is no user input.
On execute
:
- Sends
drawing-request
for every player instate.players
. - Request contains
prompt
from the top of that playersGame Stack
. - Waits for players to respond, or for 180 seconds to elapse.
- Adds placeholder images to
Game Stack
if players do not respond. - Transitions to
PassStacksAroundHandler
.
On handleInput
:
- Handler expects a
url
property in the player response message.url
points to image stored somewhere publically accessible. (We're going to useAzure storage buckets
for this later on.) - When player input is received, an
instruction
is sent to the player, prompting them towait
.
On execute:
- Sends
caption-request
for every player instate.players
. - Request contains
url
from the top of that playersGame Stack
. - Waits for players to respond, or for 60 seconds to elapse.
- Adds "Answer not submitted" to
Game Stack
if players do not respond. - Transitions to
PassStacksAroundHandler
.
On handleInput:
- Handler expects a
caption
property in the player response message. - When player input is received, an
instruction
is sent to the player, prompting them towait
.
On execute
:
- Moves the
Game Stacks
forward to the next player that is required to contribute. - If the
Game Stacks
have been moved to their original owner, transitions toGetUserScoresHandler
. - Otherwise, picks either
GetUserDrawingHandler
orGetUserCaptionHandler
. - Picks
GetUserDrawingHandler
when the top item in theGame Stack
is aCaption
. - Picks
GetUserCaptionHandler
when the top item in theGame Stack
is aDrawing
.
On execute
:
- Sends a
pick-one-request
for eachGame Stack
. - Waits for all players to submit a score for that specific
Game Stack
. - Sends the next
pick-one-request
until allGame Stacks
have been scored.
On handleInput
:
- Assigns a vote to the author of each picked
Game Stack Item
. - Handles admin input to progress the game forward and skip the user scoring, to prevent games hanging.
On execute
:
- Sends a
show-scores
message with the final scores of theGame round
.
The interesting thing about these handlers is that we're using async/await
and an unresolved Promise to pause the execution while we wait for user input. This allows us to represent the game's control flow linearly while waiting for messages to arrive over the p2p channel
.
GetUserDrawingHandler
is an example of this linear flow: First we set up an execute
method, creating an instance variable called submitted
(scoped to this
). We know that when the number of submitted
drawings is equal to the total number of players
, every player has sent an image.
async execute(state, context) {
this.submitted = 0;
...
Next, we send an instruction
to each player, in this case a drawing-request
for (let player of state.players) {
...
context.channel.sendMessage({ kind: "instruction", type: "drawing-request", ...);
}
Then we begin waiting for responses.
We use the syntax await waitUntil(() => some condition)
to do this.
const result = { transitionTo: "PassStacksAroundHandler" };
try {
await waitUntil(() => this.submitted == state.players.length, this.waitForUsersFor);
}
catch (exception) {
result.error = true;
... /* error handling */
}
return result;
}
This creates an unresolved Promise that is polling in the background and executing the function passed to it. When that function returns true
, the execution will continue, and the Promise
will resolve.
While the code is paused here, awaiting the unresolved promise, messages sent via the Ably channel will be passed to the handleInput
function of this specific handler.
async handleInput(state, context, message) {
if (message.kind == "drawing-response") {
const stackItem = new StackItem("image", message.imageUrl);
const stack = state.stacks.filter(s => s.heldBy == message.metadata.clientId)[0];
stack.add({ ...stackItem, author: message.metadata.clientId, id: createId() });
context.channel.sendMessage({ kind: "instruction", type: "wait" }, message.metadata.clientId);
this.submitted++;
}
}
The input handler increments this.submitted
each time it receives a message from Ably. Each time the waitUntil
condition runs, it checks what the current value of this.submitted
is. Eventually, enough messages will be received for the promise to resolve.
The waitUntil
call also takes a timeout value - in this example it's the instance variable this.waitForUsersFor
which is provided in the constructor. If the callback condition hasn't been reached by the moment the timer reaches this timeout value, the Promise
will be rejected, and an exception
will be thrown. This means that we can do things like handling a player taking too long to draw a picture by submitting a default image.
We'll now go over the basics of the Vue app, the P2PClient
, and how the GameStateMachine
orchestrates the gameplay.
The Vue app is split out into Vue Components
. Each component will respond to a specific Game State Instruction
message. The Game State Machine
will forward on messages received from Ably to the Vue app, so that the Game Handlers
can respond and update the UI accordingly. We'll use an HTML canvas to present the players with a way of drawing on the screen with a mouse (or fingers/pointer on touch screen devices) and capturing their input.
The UI markup is deceptively simple at the top level, because we use Vue Components
for all of the game phases.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Depict-it</title>
<meta ...>
<script src="//cdn.ably.io/lib/ably-1.js" defer></script>
<script src="//cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="/index.js" type="module"></script>
<link href="https://fonts.googleapis.com/css2?family=Sora&display=swap" rel="stylesheet" />
<link href="/style.css" rel="stylesheet" />
</head>
In the HTML head we reference the Ably JavaScript SDK, along with the Vue.js library.
We also reference the Index.js
file as a module - this means we can use native browser import
and export
module syntax. Finally, we reference a Google Gont and style.css
which contain all the styles for the UI.
The game UI is defined within the HTML main
element:
<div v-if="!joinedOrHosting" class="join-container">
<create-game-form v-on:create="host" v-on:join="join"></create-game-form>
</div>
First, we show the form that players will use to create a new game - the CreateGameForm
. This is imported from CreateGameForm.js. Using Vue's conditional rendering we can make this element show only when a player hasn't yet hosted or joined a game. Once a game has been joined or hosted, we then show the activeGame
portion of the app, which contains the components that trigger when the game is running. This is split into several parts. The first part is called the game-lobby
:
<div v-else id="activeGame" class="game-info">
<div class="game-lobby" v-if="gameCanBeStarted">
<invite-link :game-id="gameId"></invite-link>
<connected-players-summary :state="transmittedServerState"></connected-players-summary>
<ready-or-waiting-prompt :is-host="isHost" :state="transmittedServerState" v-on:startgame="startGame">
</ready-or-waiting-prompt>
</div>
<div v-if="!gameCanBeStarted && !state?.lastInstruction">
<loader></loader>
</div>
The game-lobby
references components to render invite links, connected players and prompt cards.
There is also a timer-bar
component that binds to any timeouts
sent from the host
:
<timer-bar v-if="state?.lastInstruction?.timeout != null" :countdown="state?.lastInstruction?.timeout">
</timer-bar>
Finally, we have the markup components for the different game state instructions sent from the host
. You'll spot familiar names here as they line up with the handlers that were defined earlier - one component per game phase.
<div v-if="state?.lastInstruction" class="playfield">
<playfield-wait-for-others :state="state"></playfield-wait-for-others>
<playfield-drawing :state="state" :client="depictItClient"></playfield-drawing>
<playfield-caption :state="state" :client="depictItClient"></playfield-caption>
<playfield-pick-one :state="state" :client="depictItClient" :is-host="isHost"></playfield-pick-one>
<playfield-show-scores :state="state" :is-host="isHost" v-on:nextround="nextRound"></playfield-show-scores>
</div>
</div>
</main>
</body>
</html>
Throughout the markup we're using the Vue.js syntax :state=
and :is-host
. These attributes are Vue bindings that pass values from our main Vue app down to the Vue components so that the components can use them. Likewise, the v-on:
event handlers bind functions in the Vue app to events
that the components can raise.
We touched on the layout of our index.js
file briefly at the start of this README, but let's take a look at it here in full.
export var app = new Vue({
el: '#app',
data: {
p2pClient: null,
p2pServer: null,
gameId: null,
friendlyName: null,
},
First we define some data properties. Vue
makes these properties observable
so we can bind them to the UI (whenever anything changes in these properties, the UI
will update).
Next we define some computed properties to make the HTML binding code more succinct:
computed: {
state: function () { return this.p2pClient?.state; },
transmittedServerState: function () { return this.p2pClient?.serverState; },
joinedOrHosting: function () { return this.p2pClient != null || this.p2pServer != null; },
isHost: function () { return this.p2pServer != null; },
hasMessage: function () { return this.message != null; },
gameCanBeStarted: function () { return this.transmittedServerState && !this.transmittedServerState.started },
depictItClient: function () { return this.p2pClient?.depictIt; }
},
These computed properties are used in our markup above. Where depictItClient
is used in the HTML, it is this computed property that is being referenced. Anything you bind to either needs to be in your Vue data
or your Vue computed
properties.
Finally we define our host
and join
methods to bind to clicks, along with startGame
and nextRound
to bind to events emitted by our Vue components
.
methods: {
host: async function (context) {
this.gameId = context.gameId;
this.friendlyName = context.friendlyName;
const pubSubClient = new PubSubClient((message, metadata) => {
handleMessagefromAbly(message, metadata, this.p2pClient, this.p2pServer);
});
const identity = new Identity(this.friendlyName);
this.p2pServer = new P2PServer(identity, this.gameId, pubSubClient);
this.p2pClient = new P2PClient(identity, this.gameId, pubSubClient);
await this.p2pServer.connect();
await this.p2pClient.connect();
},
join: async function (context) {
this.gameId = context.gameId;
this.friendlyName = context.friendlyName;
const pubSubClient = new PubSubClient((message, metadata) => {
handleMessagefromAbly(message, metadata, this.p2pClient, this.p2pServer);
});
const identity = new Identity(this.friendlyName);
this.p2pClient = new P2PClient(identity, this.gameId, pubSubClient);
await this.p2pClient.connect();
},
startGame: async function (evt) {
this.p2pServer?.startGame();
},
nextRound: async function (evt) {
this.p2pServer?.nextRound();
}
}
});
This is the entire outline of the top level of the app, most of the display logic is hidden in the Vue components
.
Remember, when a user joins or hosts a game, a P2PClient
or P2PServer
instance is created, and the state managed inside of them becomes observable, so we can bind any properties on these objects into the app.
At the bottom of Index.js
is also a handleMessagefromAbly
function that passes messages received over the P2P channel
onto the P2PServer
and P2PClient
instances. Let's take a quick look inside those classes again to see how this all works.
P2PServer is really where most of the game is managed.
When the host creates a game, a new instance of P2PServer
is created. In turn, this creates an instance of the GameStateMachine
with an empty this.state
object.
import { DepictIt } from "../game/DepictIt.js";
export class P2PServer {
constructor(identity, uniqueId, ably) {
this.identity = identity;
this.uniqueId = uniqueId;
this.ably = ably;
this.stateMachine = DepictIt({
channel: ably
});
this.state = {
players: [],
hostIdentity: this.identity,
started: false
};
}
Notice that when we call the imported DepictIt
function, we're passing a context object
with the property channel
set to the ably
parameter. By providing this channel to the GameStateMachine
instance, we're making sure that every time one of our Handlers
executes, it has access to the Ably channel, and can use it to send p2p messages
to all the other clients.
Next, we're going to define connect
and startGame
:
async connect() {
await this.ably.connect(this.identity, this.uniqueId);
}
async startGame() {
this.state.started = true;
this.ably.sendMessage({ kind: "game-start", serverState: this.state });
this.stateMachine.state.players = this.state.players;
this.stateMachine.run();
}
These two functions are bound into our Vue components
, and both assign the currently connected players to the stateMachine.state
property, and trigger stateMachine.run();
, which starts the game of Depict-It.
nextRound
is used to progress the game - it is a function that calls resetCurrentStepKeepingState
on our stateMachine
before invoking run
again - a function that moves the current handler back to the start without clearing player scores.
async nextRound() {
this.stateMachine.resetCurrentStepKeepingState();
this.stateMachine.run();
}
And finally, let's take a look at the vitally important onReceiveMessage
handler:
onReceiveMessage(message) {
switch (message.kind) {
case "connected": this.onClientConnected(message); break;
default: {
this.stateMachine.handleInput(message);
};
}
}
onClientConnected(message) {
this.state.players.push(message.metadata);
this.ably.sendMessage({ kind: "connection-acknowledged", serverState: this.state }, message.metadata.clientId);
this.ably.sendMessage({ kind: "game-state", serverState: this.state });
}
}
You can see that in this version of the handler, we treat clients connecting as a special case, by replying with connection-acknowledged
. We use this to update our connected
status in the debug UI element.
Most importantly however, is that any other messages are passed to the this.stateMachine.handleInput()
function.
What we're doing here is delegating responsibility for processing our messages to whichever handler
is currently active, routed via the GameStateMachine
instance. This is the glue that takes a message received by our Ably connection
, and passes it through our GameStateMachine
to the currently active Handler
.
The P2PClient
that gets created when anyone joins a game follows the same general pattern as the P2PServer
.
First we have a constructor that creates some state, and a few properties that our game is going to use:
depictIt
is going to store a client wrapper that we'll later bind into some Vue components
, and the this.state
object contains both an instructionHistory
as well as a lastInstruction
for us to track all the messages this client has received from the host
.
import { DepictItClient } from "../game/DepictIt.js";
export class P2PClient {
constructor(identity, uniqueId, ably) {
this.identity = identity;
this.uniqueId = uniqueId;
this.ably = ably;
this.depictIt = null;
this.serverState = null;
this.state = {
status: "disconnected",
instructionHistory: [],
lastInstruction: null
};
}
Next, much like in P2PServer
, we define a connect
function that sends a message to the host
and waits for acknowledgement
. We're also creating an instance of DepictItClient
- a class that offers a function for each response the client
has to send back to the host
. We bind this to the Vue components
so they can reply to the host
when they have player input.
async connect() {
await this.ably.connect(this.identity, this.uniqueId);
this.ably.sendMessage({ kind: "connected" });
this.state.status = "awaiting-acknowledgement";
this.depictIt = new DepictItClient(this.uniqueId, this.ably);
}
And finally we have the onReceiveMessage
function.
The most important piece here, is that when the P2Pclient
receives a message with a kind
of instruction
, it'll store a copy of it into the instructionHistory
and will assign the most recent message to the property this.lastInstruction
.
onReceiveMessage(message) {
if (message.serverState) {
this.serverState = message.serverState;
}
switch (message.kind) {
case "connection-acknowledged":
this.state.status = "acknowledged";
break;
case "instruction":
this.state.instructionHistory.push(message);
this.state.lastInstruction = message;
break;
default: { };
}
}
}
Practically all of the UI is going to be bound-up to the values in the lastInstruction
property. It is the most important piece of data in the entire application.
Vue components
let us split out parts of the functionality into what looks like separate Vue apps. They follow practically the same syntax, but contain both the UI template and the JavaScript.
The Depict-It app is split into a bunch of smaller components:
base-components/CopyableTextBox.js
base-components/DrawableCanvas.js
ConnectedPlayersSummary.js
CreateGameForm.js
InviteLink.js
Loader.js
PlayfieldCaption.js
PlayfieldDrawing.js
PlayfieldPickOne.js
PlayfieldShowScores.js
PlayfieldWaitForOthers.js
ReadyOrWaitingPrompt.js
StackItem.js
TimerBar.js
Keeping to a sensible convention, the component name has the phase of the game it's associated with in the filename.
Let's look inside StackItem
as an example, since it is a simple component:
export const StackItem = {
props: ['item'],
methods: {
emitIdOfClickedElement: async function () {
this.$emit('click', this.item.id);
}
},
template: `
<span v-if="item.type == 'string'"
v-on:click="emitIdOfClickedElement"
class="stack-item stack-text">{{ item.value }}</span>
<img v-else
v-bind:src="item.value"
v-on:click="emitIdOfClickedElement"
class="stack-item" />
`
};
Vue Components:
- Can have named
props
that you can bind inHTML
using the:prop-name="something"
syntax. - Can have methods.
- Can have computed properties.
- Have a template string.
In the example of the StackItem
, there is a v-if
and v-else
statement displaying a span
if the item is a string
(a caption), or an img
tag when the item is a drawing
.
All of the components follow a similar pattern - capturing bits of interaction.
The other piece of syntax you can see here is the this.$emit
function call.
Doing this allows us to define custom events that can be bound in the consuming Vue component
or Vue app
- so if we emit an event, in the parent we can use the v-on
syntax to listen and respond to it. In this case, we're creating an event called click
, and passing the item.id
of the selected Stack Item
to subscribers of that event.
Let's now take a look at the PlayfieldDrawing
component to see how we handle sending data back from the server.
export const PlayfieldDrawing = {
props: ['state', 'client'],
methods: {
sendImage: async function (base64EncodedImage) {
await this.client.sendImage(base64EncodedImage);
}
},
template: `
<section v-if="state?.lastInstruction?.type == 'drawing-request'">
<div class="drawing-prompt">
<div class="prompt-front">Draw This</div>
<div class="prompt-back">
{{ state.lastInstruction.value }}
</div>
</div>
<drawable-canvas v-on:drawing-finished="sendImage"></drawable-canvas>
</section>
`
};
This Vue component
is typical of the others that require interactivity. Remember when we walked through P2PClient
and we created an instance of DepictItClient
? We've bound that client into the Vue component
as the property client
. What this means, is that when our sendImage
function is triggered by the DrawableCanvas
raising the drawing-finished
event, we can use that client to send an image back to the host
.
This general pattern holds for collecting captions and scoring our game.
Our DepictIt Client
is a small wrapper class around all of our components' interactions with the host
.
This client is responsible for sending data and little else.
All those messages that are expected? They're all defined here.
export class DepictItClient {
constructor(gameId, channel) {
this.gameId = gameId;
this.channel = channel;
}
async sendImage(base64EncodedImage) {
...
}
async sendCaption(caption) {
this.channel.sendMessage({ kind: "caption-response", caption: caption });
}
async logVote(id) {
this.channel.sendMessage({ kind: "pick-one-response", id: id });
}
async hostProgressedVote() {
this.channel.sendMessage({ kind: "skip-scoring-forwards" })
}
}
There is one extra interesting function in here though - sendImage
.
async sendImage(base64EncodedImage) {
const result = await fetch("/api/storeImage", {
method: "POST",
body: JSON.stringify({ gameId: this.gameId, imageData: base64EncodedImage })
});
const savedUrl = await result.json();
this.channel.sendMessage({ kind: "drawing-response", imageUrl: savedUrl.url });
}
sendImage
has to POST the base64EncodedImage
that's created by our DrawableCanvas
component to an API
running on our instance of Azure Functions
before it sends a message back to the host
.
To make our images work, we've added an extra function to the directory /api/storeImage/index.js:
const { StorageSharedKeyCredential } = require("@azure/storage-blob");
const { BlobServiceClient } = require("@azure/storage-blob");
function uuidv4() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
module.exports = async function (context, req) {
const defaultAzureCredential = new StorageSharedKeyCredential(process.env.AZURE_ACCOUNT, process.env.AZURE_KEY);
const blobServiceClient = new BlobServiceClient(process.env.AZURE_BLOBSTORAGE, defaultAzureCredential);
const containerClient = blobServiceClient.getContainerClient(process.env.AZURE_CONTAINERNAME);
const unique = `game_${req.body.gameId}_${uuidv4()}.png`;
const url = `${process.env.AZURE_BLOBSTORAGE}/${process.env.AZURE_CONTAINERNAME}/${unique}`;
const fileData = req.body.imageData.replace(/^data:image\/\w+;base64,/, "");
const buffer = new Buffer(fileData, 'base64');
const blockBlobClient = containerClient.getBlockBlobClient(unique);
const uploadBlobResponse = await blockBlobClient.upload(buffer, buffer.length || 0);
context.res = {
headers: { "content-type": "application/json" },
body: { url: url }
};
};
This you may recognize as boiler-plate, it's the standard Azure Blob Storage SDK
code to upload a file to a storage bucket. This Azure function
is mounted by the Azure functions runtime
to the path /api/storeImage
so we can call it using our browser's Fetch API
.
The function returns an absolute url
of the stored image - which is stored in a bucket that supports unauthenticated reads
.
The bucket is also configured to auto-delete items after 24-hours to keep our storage costs really low.
This is a super quick way to add a little bit of statefulness to our app - especially because the average size of our images is over the message size cap for Ably messages
.
We have a Vue component
that we use to handle drawing with a mouse, or "finger painting" on touch devices.
export const DrawableCanvas = {
...
mounted: function () {
const element = document.getElementById(this.canvasId);
if (element && !this.canvas) {
this.canvas = new DrawableCanvasElement(this.canvasId).registerPaletteElements(this.paletteId);
}
},
...
template: `
<div class="drawable-canvas">
<div class="canvas-and-paints">
<canvas v-bind:id="canvasId" class="image-frame paint-canvas" width="400" height="400"></canvas>
<div v-bind:id="paletteId" class="palette">
<div style="background-color: black;" v-on:click="colorSelected"></div>
<div style="background-color: red;" v-on:click="colorSelected"></div>
<div style="background-color: green;" v-on:click="colorSelected"></div>
<div style="background-color: blue;" v-on:click="colorSelected"></div>
<div style="background-color: white;" v-on:click="eraserSelected"></div>
</div>
</div>
<button v-on:click="$emit('drawing-finished', canvas.toString())" class="form-button finished-drawing">I'm finished!</button>
</div>`
};
There are two important things about this component
. Firstly, we use the class DrawableCanvasElement
from the npm package @snakemode/snake-canvas
(This package was built while writing this game).
We emit an event called drawing-finished
when the user clicks the I'm finished button in the template. This event is listened to in the consuming Vue component
- in this case, the PlayfieldDrawing
component that deals with the drawing phase of the game. As an event, we pass the result of the function call canvas.toString()
- this is a thin wrapper around the native browser call to convert a HTML Canvas element to a base64-encoded PNG. The consuming component then uses this to upload images to our Azure Blob Storage
account.
There's not too much to the drawing canvas - it takes an element Id (that it presumes is on a HTML Canvas element), and adds some click handlers on mouse up/down/move. Whenever the mouse is moved, a line between the last position and the current one is drawn, and a 1px blur applied to smooth out the aliasing in the image.
You might notice that we're also calling the function registerPaletteElements
. This adds a click handler to each child element of the passed in Id (the palette elements). When they are clicked, the active colour is set to the background colour of the clicked palette.
This means we can add and remove colours to our drawable canvas at will.
The canvas also has touch support - we have to do a little bit of maths to make sure we're using the correct x and y coordinates in our canvas to support both mouse and touch. Multi-touch isn't supported.
getLocationFrom(e) {
const location = { x: 0, y: 0 };
if (e.constructor.name === "TouchEvent") {
const bounds = e.target.getBoundingClientRect();
const touch = e.targetTouches[0];
location.x = touch.clientX - bounds.left;
location.y = touch.clientY - bounds.top;
} else {
location.x = e.offsetX;
location.y = e.offsetY;
}
return location;
}
This function is used to work out exactly where the player is drawing on our canvas - using either the mouse position, or the position of the first touch event.
We've spoken at length about how the core pieces of this game hang together.
If you want a deeper understanding, the code is all here, and you can run it locally by pulling this repo, and executing npm run start
, once you've added API keys for Ably, and Azure Blob Storage into the /api/local.settings.json
file.
While this whole application runs inside a browser, we need some kind of backend to keep our Ably API key
safe. The running version of this app is hosted on Azure Static Web Apps (preview)
and provides us a serverless
function that we can use to implement Ably Token Authentication.
We need to keep the Ably API key
on the server side, so people can't grab it and eat up your usage quota. The client side SDK knows how to request a temporary key from an API call, we just need something to host it. In the api
directory, there's code for an Azure Functions
API that implements this Token Authentication
behaviour.
Azure Static Web Apps
automatically hosts this API for us, because there are a few .json files in the right places that it's looking for and understands. To have this same experience locally, we'll need to use the Azure Functions Core Tools.
We use live-server to serve our static files, and Azure functions for interactivity
npm install -g live-server
npm install -g azure-functions-core-tools
To set your API key for local development:
cd api
func settings add ABLY_API_KEY Your-Ably-Api-Key
Running this command will encrypt your API key into the file /api/local.settings.json
. You don't need to check it in to source control, even if you do, it won't be usable on another machine.
Next you need to Create an Azure Blob Storage Account, create a container, a storage bucket, and generate an API key.
Please refer to the Azure documentation for this. Once you know all your Azure configuration, you can either edit your local.settings.json
file by hand, or add to it using the func
command as demonstrated above. You'll need to add the following keys:
AZURE_ACCOUNT
AZURE_CONTAINERNAME
AZURE_BLOBSTORAGE
AZURE_KEY
Here is an example of an unencrypted local.settings.json file:
{
"IsEncrypted": false,
"Values": {
"ABLY_API_KEY": "ably-api-key-here",
"AZURE_ACCOUNT": "scrawlimages",
"AZURE_CONTAINERNAME": "gameimages",
"AZURE_BLOBSTORAGE": "https://scrawlimages.blob.core.windows.net",
"AZURE_KEY": "some-azure-access-token-from-the-storage-account",
"FUNCTIONS_WORKER_RUNTIME": "node"
},
"ConnectionStrings": {}
}
To run the Depict-It app, first install the npm modules and the modules in the api directory, then back in the root directory, run the start script:
npm install
cd api
npm install
cd ../
npm run start
We're hosting this project as an Azure Static Web App - and the deployment information is in hosting.md.