Realtime Bingo - Ablingo!
Ablingo is a peer-to-peer bingo app, that runs in your browser tabs, powered by Ably.io. It uses Ably Realtime Channels to connect between clients. It's written in Vue.js, runs locally, and in this example, is hosted on Azure Static Web Apps.
Contents
- The bingo messages
- The message flow
- A note on security
- The basics of a Vue app
- The app
- Web Speech API
- Set up a free account with Ably
- Hosting on Azure
The rules of bingo
The bingo card
There are six bingo tickets per card. Our bingo tickets contain 27 spaces, arranged in 9 columns and 3 rows. Each row contains 5 numbers and four blank spaces. Each column on the card can contain the numbers from it's own base ten (for example column 0 can contain 1-9, column 1 can contain 10-19, column 2 can contain 2-29 etc). All of the numbers between 1 and 90 will appear across the six tickets, meaning that the player will mark a number every time one is called.
Playing the game
The game is progressed by the caller (who is in this case automatic). The caller will call numbers as they are randomly selected. As each number is called, players check to see where it appears on their tickets. If found, they mark it off by clicking the square containing the number. When all of the numbers required to win a prize have been marked off then the player clicks the 'Bingo' button and the game will check whether they have checked off their numbers correctly and whether or not they are the first player to claim that prize. If they are a winner their player name will be added to the list of prize winners.
Prize winning combinations
- Single Line – covering a horizontal line of five numbers on the ticket.
- Two Lines – covering any two lines on the same ticket.
- Full House – covering all fifteen numbers on the ticket
How our peer to peer game works
We're using Ably Channels to provide a peer-to-peer messaging capability to our Bingo game.
We've split our code into two JavaScript classes:
BingoClient
found in bingo.lib.client.jsBingoServer
found in bingo.lib.server.js
Both of these classes use logic found in bingo.js - where all our code capturing bingo game rules, calling, and scoring lives.
The UI generates a random GameId
from a list of random animal names (from dictionaries.js
) stitching together combinations of three names with hyphens. This GameId
is used as our Ably Channel Name
- so when different browsers connect to this (probably!) unique Channel Name
, messages can be sent between participants in the game.
We also use the animal list to auto-generate player names, because they're fun.
The game begins with one player electing themselves host by clicking the host button in the UI. The app then creates an instance of our BingoServer
class in that players browser - where it's stored as part of our Vue.js
data (more on that later).
All the games players have an instance of our BingoClient
class created and put in Vue data too - including the Host
- as they're also a player in the game.
When either a host starts hosting, or a player joins the game, a connection to Ably
is opened, and all players
are subscribed to the uniquely named channel. The bingo game then plays out through a series of messages sent from the Host
to all the Players
(including themselves!) on a tick
timer.
All the code in bingo.js is used by the Host
to run the logic of the bingo game, with our BingoCaller
class selecting a new numbers as we play.
This "one player is the host" pattern is the same way peer to peer games work everywhere, but instead of directly establishing connections between all our players, we're using Ably Channels to make the networking part of our games much easier.
Default message contents
Messages from the host always contain a property called serverState
, which the clients
use to stay in sync.
Messages are all sent multicast (to and from everyone subscribed to the channel at the same time)
The server looks like this:
this.state = {
settings: { server: identity, gameId: gameId, automark: false },
status: "not-started",
players: [],
prizes: this.defaultPrizesObject()
};
defaultPrizesObject() { return { "one-line": null, "two-line": null, "full-house": null } };
Direct messages
While all messages are multicast - by convention, if a clientId is provided to the function sendMessage
on our PubSubClient
class, an extra property will be added to let client
instances to either process or ignore this message.
sendMessage({ kind: "some-message", serverState: this.state }, message.metadata.clientId);
Please remember that this is not secure and all clients will still receive messages destined for each player, but our BingoClient
knows to filter out these messages from other clients, so they don't process them.
This filter exists in index.js when we connect to our Ably Channel
and looks like this:
function shouldHandleMessage(message, metadata) {
return !message.forClientId || (message.forClientId && message.forClientId === metadata.clientId);
}
function handleMessagefromAbly(message, metadata, gameClient, gameServer) {
if (shouldHandleMessage(message, metadata)) {
gameServer?.onReceiveMessage(message);
gameClient?.onReceiveMessage(message);
}
}
We check if the property forClientId
is in the received message, and if it is, only process the message when it it for that client
.
We use this feature to issue bingo cards and acknowledge players joining a game.
The Host
The host
computer runs the logic of the game, and gets provided extra options in the UI than a regular player
The bingo messages
Because this is peer to peer, we send a lot of messages between the player that is hosting, and all the other players.
Message | State Change | Notes |
---|---|---|
connected | Client connects to game | Sent from client |
connection-acknowledged | Client connects to game | Sent to specific client |
new-game | Host starts game | All clients clear state |
host-offer | Host starts hosting on channel | |
host-reject | Existing host rejects new one | Prevents two hosts in same channel |
game-info | Start of game, client connected | |
bingo-card-issued | Pre-game-start | Sent to specific client |
bingo-caller-message | Every game tick on the host | |
bingo | When client clicks bingo | Sent from a client |
prize-awarded | When a new award is made | |
game-complete | End of game |
The message flow
The message flow orchestrates the game of bingo between the Host Player
and all the Clients
.
The UI, and game scoring, is determined by which messages are sent and received by the Players
.
- A host sends out a host-offer message when they start hosting, likely nobody is listening.
- A client joins a session and sends a
connected
message - The host sends a
connection-acknowledged
message to that specific client Host Player
clicksstart
andnew-game
message is sent wiping any clients connected stateHost
sends all connected clients abingo-card-issued
with their numbersHost
sendsgame-info
message to make sure clients are all in sync.- Game ticks forward and a
bingo-caller-message
is sent for each number. Player
clicksbingo
and abingo
message is sent, along with the number they claim to have seen.Host
marks thebingo
request, and if it satisfies a prize rule, aprize-awarded
message is sent.- Once a full-house is called, or all the possible numbers have been called, a
game-complete
message is sent.
Once the game is complete, the host can start a new game, with the same players, on the same GameId, by clicking start
again.
A note on security
Because this game works peer to peer, in theory, a player could join the channel and start sending host messages. In a more mission critical setup, you would either sign the messages, or verify the host on message receipt. Please don't use this sample, if you're building systems that need to be tamper-proof.
The basics of a Vue app
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 is a quick-to-start-with single-page-app framework, and we've used it to build our UI. Our Vue code lives in index.js
- and handles all of our user interactions.
Our Vue app looks a little like this abridged sample:
var app = new Vue({
el: '#app',
data: {
gameClient: null
...
},
watch: {
soundEnabled: function (val) { this.gameClient.soundEnabled = val; },
bingoAvailable: function (val) { this.bingoAvailable = val; }
},
computed: {
state: function() { return this.gameClient?.state; },
...
},
methods: {
startGame: function(evt) { ... },
hostGame: async function(evt) { ... },
}
});
It finds an element with the ID of app
in our markup, and treats any elements inside of it as markup that can contain Vue Directives
- extra attributes to bind data and manipulate our HTML based on our applications state.
Typically, the Vue app makes data available (such as gameClient
in the above code snippet), and when that data changes, it'll re-render the parts of the UI that are bound to it. We also make use of watch
(for handling user input) and computed
data properties in the above sample.
Vue also exposes a method
property, where we implement things like click handlers, and callbacks from our UI.
This snippet from our Game Over screen markup, should help illustrate how Vue if-statements and markup works
<div v-if="gameComplete" class="game-info game-finished">
<h1>Game Finished!</h1>
<p>Winner: {{ state?.winner?.friendlyName }}</p>
<p class="reason">Reason: {{ state?.gameStateReason }}</p>
<h2 class="play-again" v-if="youAreHost">Play again?</h2>
</div>
Here you'll see Vue's v-if
directive, which means that this div
will only display if the gameComplete
data
property is true.
You can also see Vue's binding syntax, where we use {{ state?.winner?.friendlyName }}
to bind some data to our UI.
Vue is simple to get started with, especially with a small app like this, with easy to understand data-binding syntax.
The app
Our UI is a Vue.js single page app. It's split into three sections
- The Lobby
- The Bingo Game
- The Game Over screen
We use Vue v-if
directives to switch between which UI elements are shown based on the gameState
.
Our gameState
reflects if we are connected to a currently hosted game, hosting a game ourselves, or disconnected.
Our app shows a list of connect players, some additional host controls for the host allowing them to start the game (with a few other options), and random generates player names and game names for people to enjoy.
The app also supports "deep linking" where you can send invite only
links to your friends, which will hide the Host
button, to make it easier for them to get straight into the game of Ablingo.
Web Speech API
The game also supports the Web Speech API - which will vocalise the bingo calls from our game when messages from the host
are received.
It's a bit of silly fun, and there's a checkbox to disable it in case it gets a little... too... annoying ;)
Error Handling
There are a few error cases that we will try to catch to in our index.js
Vue app:
- The host disconnecting
- A client disconnecting
- A user trying to connect to a game that is already in progress
The Ably SDK provides a callback when the websocket connection it uses is in a disconnected
state.
If this happens, and you're a game host, you're (obviously!) unable to send messages to keep your game running.
For the sake of this real-time game, this is an unrecoverable error, and you'll have to create a new game when your internet connection is resumed. (In testing, this was normally due to WiFi dropping out, and there's not too much we can do about that!)
In that same scenario as a client, equally, you can't really finish your game, because you'll have missed numbers that have been called. You can rejoin at the end of the current session, and be issued with a fresh bingo card.
The final scenario that we're concerned with, are users attempting to join mid-game. It doesn't really make much sense to support this, so we just redirect them with an error message. They too, can join at the end of the current session.
These error callbacks are handled at the bottom of index.js
in these three functions
function onHostDisconnected() { ... }
function onClientDisconnected() { ... }
function onGameAlreadyStartedError() { ... }
Set up a free account with Ably
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 set up our dev environment.
Running on your machine
While this whole application runs inside a browser, to host it anywhere people can use, 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
.
The short version is - we need to keep the Ably API key
on the server side, so people can't grab it and use 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
.
Local dev pre-requirements
We'll 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
Set your API key for local dev:
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, and even if you do, it won't be usable on another machine.
How to run for local dev
Run the bingo app:
npx live-server --proxy=/api:http://127.0.0.1:7071/api
And run the APIs
cd api
npm run start
or with this bash
one-liner
npm run start --prefix api & npx live-server --proxy=/api:http://127.0.0.1:7071/api
There are various bash / batch files in the repository root, so in practice you can just type
run
to do all of the above.